merge branch heads

This commit is contained in:
Sebastian Sdorra
2018-08-02 11:57:58 +02:00
8 changed files with 477 additions and 24 deletions

View File

@@ -2,5 +2,9 @@
"overview": {
"title": "Repositories",
"subtitle": "Overview of available repositories"
},
"repository-root": {
"error-title": "Error",
"error-subtitle": "Unknown repository error"
}
}

View File

@@ -12,6 +12,7 @@ import { Switch } from "react-router-dom";
import ProtectedRoute from "../components/ProtectedRoute";
import AddUser from "../users/containers/AddUser";
import SingleUser from "../users/containers/SingleUser";
import RepositoryRoot from '../repos/containers/RepositoryRoot';
type Props = {
authenticated?: boolean
@@ -32,6 +33,18 @@ class Main extends React.Component<Props> {
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repos/:page"
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repo/:namespace/:name"
component={RepositoryRoot}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/users"

View File

@@ -0,0 +1,16 @@
//@flow
import React from "react";
import type {Repository} from '../types/Repositories';
type Props = {
repository: Repository
};
class RepositoryDetails extends React.Component<Props> {
render() {
const { repository } = this.props;
return <div>{repository.description}</div>;
}
}
export default RepositoryDetails;

View File

@@ -4,26 +4,48 @@ import React from "react";
import type { RepositoryCollection } from "../types/Repositories";
import { connect } from "react-redux";
import {fetchRepos, getFetchReposFailure, getRepositoryCollection, isFetchReposPending} from "../modules/repos";
import {fetchRepos, fetchReposByLink, fetchReposByPage, getFetchReposFailure, getRepositoryCollection, isFetchReposPending} from "../modules/repos";
import { translate } from "react-i18next";
import { Page } from "../../components/layout";
import RepositoryList from "../components/RepositoryList";
import Paginator from '../../components/Paginator';
import {withRouter} from 'react-router-dom';
import type { History } from "history";
type Props = {
page: number,
collection: RepositoryCollection,
loading: boolean,
error: Error,
// dispatched functions
fetchRepos: () => void,
fetchReposByPage: number => void,
fetchReposByLink: string => void,
// context props
t: string => string
t: string => string,
history: History
};
class Overview extends React.Component<Props> {
componentDidMount() {
this.props.fetchRepos();
this.props.fetchReposByPage(this.props.page);
}
/**
* reflect page transitions in the uri
*/
componentDidUpdate() {
const { page, collection } = this.props;
if (collection) {
// backend starts paging by 0
const statePage: number = collection.page + 1;
if (page !== statePage) {
this.props.history.push(`/repos/${statePage}`);
}
}
};
render() {
const { error, loading, t } = this.props;
return (
@@ -34,21 +56,36 @@ class Overview extends React.Component<Props> {
}
renderList() {
const { collection } = this.props;
const { collection, fetchReposByLink } = this.props;
if (collection) {
return (
<RepositoryList repositories={collection._embedded.repositories} />
<div>
<RepositoryList repositories={collection._embedded.repositories} />
<Paginator collection={collection} onPageChange={fetchReposByLink} />
</div>
);
}
return null;
}
}
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const page = getPageFromProps(ownProps);
const collection = getRepositoryCollection(state);
const loading = isFetchReposPending(state);
const error = getFetchReposFailure(state);
return {
page,
collection,
loading,
error
@@ -59,10 +96,16 @@ const mapDispatchToProps = dispatch => {
return {
fetchRepos: () => {
dispatch(fetchRepos());
},
fetchReposByPage: (page: number) => {
dispatch(fetchReposByPage(page))
},
fetchReposByLink: (link: string) => {
dispatch(fetchReposByLink(link))
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(Overview));
)(translate("repos")(withRouter(Overview)));

View File

@@ -0,0 +1,108 @@
//@flow
import React from "react";
import {fetchRepo, getFetchRepoFailure, getRepository, isFetchRepoPending} from '../modules/repos';
import { connect } from "react-redux";
import {Route} from "react-router-dom"
import type {Repository} from '../types/Repositories';
import {Page} from '../../components/layout';
import Loading from '../../components/Loading';
import ErrorPage from '../../components/ErrorPage';
import { translate } from "react-i18next";
import {Navigation} from '../../components/navigation';
import RepositoryDetails from '../components/RepositoryDetails';
type Props = {
namespace: string,
name: string,
repository: Repository,
loading: boolean,
error: Error,
// dispatch functions
fetchRepo: (namespace: string, name: string) => void,
// context props
t: string => string,
match: any
};
class RepositoryRoot extends React.Component<Props> {
componentDidMount() {
const { fetchRepo, namespace, name } = this.props;
fetchRepo(namespace, name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { loading, error, repository, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("repository-root.error-title")}
subtitle={t("repository-root.error-subtitle")}
error={error}
/>
);
}
if (!repository || loading) {
return <Loading />
}
const url = this.matchedUrl();
return <Page title={repository.namespace + "/" + repository.name}>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={() => <RepositoryDetails repository={repository} />} />
</div>
<div className="column">
<Navigation>
</Navigation>
</div>
</div>
</Page>
}
}
const mapStateToProps = (state, ownProps) => {
const { namespace, name } = ownProps.match.params;
const repository = getRepository(state, namespace, name);
const loading = isFetchRepoPending(state, namespace, name);
const error = getFetchRepoFailure(state, namespace, name);
return {
namespace,
name,
repository,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRepo : (namespace: string, name: string) => {
dispatch(fetchRepo(namespace, name))
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(RepositoryRoot));

View File

@@ -2,7 +2,7 @@
import { apiClient } from "../../apiclient";
import * as types from "../../modules/types";
import type { Action } from "../../types/Action";
import type { RepositoryCollection } from "../types/Repositories";
import type {Repository, RepositoryCollection} from "../types/Repositories";
import {isPending} from "../../modules/pending";
import {getFailure} from "../../modules/failure";
@@ -11,14 +11,44 @@ export const FETCH_REPOS_PENDING = `${FETCH_REPOS}_${types.PENDING_SUFFIX}`;
export const FETCH_REPOS_SUCCESS = `${FETCH_REPOS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_REPOS_FAILURE = `${FETCH_REPOS}_${types.FAILURE_SUFFIX}`;
export const FETCH_REPO = "scm/repos/FETCH_REPO";
export const FETCH_REPO_PENDING = `${FETCH_REPO}_${types.PENDING_SUFFIX}`;
export const FETCH_REPO_SUCCESS = `${FETCH_REPO}_${types.SUCCESS_SUFFIX}`;
export const FETCH_REPO_FAILURE = `${FETCH_REPO}_${types.FAILURE_SUFFIX}`;
const REPOS_URL = "repositories";
// fetch repos
const SORT_BY = "sortBy=namespaceAndName";
export function fetchRepos() {
return fetchReposByLink(REPOS_URL);
}
export function fetchReposByPage(page: number) {
return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`);
}
function appendSortByLink(url: string) {
if (url.includes(SORT_BY)) {
return url;
}
let urlWithSortBy = url;
if (url.includes("?")) {
urlWithSortBy += "&";
} else {
urlWithSortBy += "?";
}
return urlWithSortBy + SORT_BY;
}
export function fetchReposByLink(link: string) {
const url = appendSortByLink(link);
return function(dispatch: any) {
dispatch(fetchReposPending());
return apiClient
.get(`${REPOS_URL}?${SORT_BY}`)
.get(url)
.then(response => response.json())
.then(repositories => {
dispatch(fetchReposSuccess(repositories));
@@ -49,15 +79,66 @@ export function fetchReposFailure(err: Error): Action {
};
}
// fetch repo
export function fetchRepo(namespace: string, name: string) {
return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name));
return apiClient.get(`${REPOS_URL}/${namespace}/${name}`)
.then(response => response.json())
.then( repository => {
dispatch(fetchRepoSuccess(repository))
} )
.catch(err => {
dispatch(fetchRepoFailure(namespace, name, err))
});
}
}
export function fetchRepoPending(namespace: string, name: string): Action {
return {
type: FETCH_REPO_PENDING,
payload: {
namespace,
name
},
itemId: namespace + "/" + name
};
}
export function fetchRepoSuccess(repository: Repository): Action {
return {
type: FETCH_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function fetchRepoFailure(namespace: string, name: string, error: Error): Action {
return {
type: FETCH_REPO_FAILURE,
payload: {
namespace,
name,
error
},
itemId: namespace + "/" + name
};
}
// reducer
function createIdentifier(repository: Repository) {
return repository.namespace + "/" + repository.name;
}
function normalizeByNamespaceAndName(
repositoryCollection: RepositoryCollection
) {
const names = [];
const byNames = {};
for (const repository of repositoryCollection._embedded.repositories) {
const identifier = repository.namespace + "/" + repository.name;
const identifier = createIdentifier(repository);
names.push(identifier);
byNames[identifier] = repository;
}
@@ -72,20 +153,34 @@ function normalizeByNamespaceAndName(
};
}
const reducerByNames = (state: Object, repository: Repository) => {
const identifier = createIdentifier(repository);
const newState = {
...state,
byNames: {
...state.byNames,
[identifier]: repository
}
};
return newState;
};
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_REPOS_SUCCESS:
if (action.payload) {
return normalizeByNamespaceAndName(action.payload);
} else {
// TODO ???
return state;
}
return normalizeByNamespaceAndName(action.payload);
case FETCH_REPO_SUCCESS:
return reducerByNames(state, action.payload);
default:
return state;
return state;
}
}
@@ -113,3 +208,17 @@ export function isFetchReposPending(state: Object) {
export function getFetchReposFailure(state: Object) {
return getFailure(state, FETCH_REPOS);
}
export function getRepository(state: Object, namespace: string, name: string) {
if (state.repos && state.repos.byNames) {
return state.repos.byNames[ namespace + "/" + name];
}
}
export function isFetchRepoPending(state: Object, namespace: string, name: string) {
return isPending(state, FETCH_REPO, namespace + "/" + name);
}
export function getFetchRepoFailure(state: Object, namespace: string, name: string) {
return getFailure(state, FETCH_REPO, namespace + "/" + name);
}

View File

@@ -8,7 +8,21 @@ import reducer, {
fetchRepos,
FETCH_REPOS_FAILURE,
fetchReposSuccess,
getRepositoryCollection, FETCH_REPOS, isFetchReposPending, getFetchReposFailure
getRepositoryCollection,
FETCH_REPOS,
isFetchReposPending,
getFetchReposFailure,
fetchReposByLink,
fetchReposByPage,
FETCH_REPO,
fetchRepo,
FETCH_REPO_PENDING,
FETCH_REPO_SUCCESS,
FETCH_REPO_FAILURE,
fetchRepoSuccess,
getRepository,
isFetchRepoPending,
getFetchRepoFailure
} from "./repos";
import type { Repository, RepositoryCollection } from "../types/Repositories";
@@ -194,7 +208,9 @@ const repositoryCollectionWithNames: RepositoryCollection = {
};
describe("repos fetch", () => {
const REPOS_URL = "/scm/api/rest/v2/repositories?sortBy=namespaceAndName";
const REPOS_URL = "/scm/api/rest/v2/repositories";
const SORT = "sortBy=namespaceAndName";
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
const mockStore = configureMockStore([thunk]);
afterEach(() => {
@@ -203,7 +219,25 @@ describe("repos fetch", () => {
});
it("should successfully fetch repos", () => {
fetchMock.getOnce(REPOS_URL, repositoryCollection);
fetchMock.getOnce(REPOS_URL_WITH_SORT, repositoryCollection);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchRepos()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch page 42", () => {
const url = REPOS_URL + "?page=42&" + SORT;
fetchMock.getOnce(url, repositoryCollection);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
@@ -215,13 +249,58 @@ describe("repos fetch", () => {
const store = mockStore({});
return store.dispatch(fetchRepos()).then(() => {
return store.dispatch(fetchReposByPage(43)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch repos from link", () => {
fetchMock.getOnce(
REPOS_URL + "?" + SORT + "&page=42",
repositoryCollection
);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store
.dispatch(
fetchReposByLink("/repositories?sortBy=namespaceAndName&page=42")
)
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should append sortby parameter and successfully fetch repos from link", () => {
fetchMock.getOnce(
"/scm/api/rest/v2/repositories?one=1&sortBy=namespaceAndName",
repositoryCollection
);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByLink("/repositories?one=1")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => {
fetchMock.getOnce(REPOS_URL, {
fetchMock.getOnce(REPOS_URL_WITH_SORT, {
status: 500
});
@@ -233,6 +312,48 @@ describe("repos fetch", () => {
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully fetch repo slarti/fjords", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: FETCH_REPO_PENDING,
payload: {
namespace: "slarti",
name: "fjords"
},
itemId: "slarti/fjords"
},
{
type: FETCH_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
expect(actions[1].payload.namespace).toBe("slarti");
expect(actions[1].payload.name).toBe("fjords");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("slarti/fjords");
});
});
});
describe("repos reducer", () => {
@@ -264,10 +385,14 @@ describe("repos reducer", () => {
expect(newState.byNames["hitchhiker/restatend"]).toBe(hitchhikerRestatend);
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
});
it("should store the repo at byNames", () => {
const newState = reducer({}, fetchRepoSuccess(slartiFjords));
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
});
});
describe("repos selectors", () => {
const error = new Error("something goes wrong");
it("should return the repositories collection", () => {
@@ -311,4 +436,39 @@ describe("repos selectors", () => {
it("should return undefined when fetch repos did not fail", () => {
expect(getFetchReposFailure({})).toBe(undefined);
});
it("should return the repository collection", () => {
const state = {
repos: {
byNames: {
"slarti/fjords": slartiFjords
}
}
};
const repository = getRepository(state, "slarti", "fjords");
expect(repository).toEqual(slartiFjords);
});
it("should return true, when fetch repo is pending", () => {
const state = {
pending: {
[FETCH_REPO + "/slarti/fjords"]: true
}
};
expect(isFetchRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when fetch repo is not pending", () => {
expect(isFetchRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error when fetch repo did fail", () => {
const state = {
failure: {
[FETCH_REPO + "/slarti/fjords"]: error
}
};
expect(getFetchRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
});

View File

@@ -50,9 +50,9 @@ class Users extends React.Component<Props> {
/**
* reflect page transitions in the uri
*/
componentDidUpdate = (prevProps: Props) => {
componentDidUpdate() {
const { page, list } = this.props;
if (list.page) {
if (list && (list.page || list.page === 0)) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {