diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 5117f64928..428f723f37 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -2,5 +2,9 @@ "overview": { "title": "Repositories", "subtitle": "Overview of available repositories" + }, + "repository-root": { + "error-title": "Error", + "error-subtitle": "Unknown repository error" } } diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index c1dcf54908..1c5cface29 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -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 { component={Overview} authenticated={authenticated} /> + + { + render() { + const { repository } = this.props; + return
{repository.description}
; + } +} + +export default RepositoryDetails; diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js index 44949cb5bb..ab85b04424 100644 --- a/scm-ui/src/repos/containers/Overview.js +++ b/scm-ui/src/repos/containers/Overview.js @@ -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 { 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 { } renderList() { - const { collection } = this.props; + const { collection, fetchReposByLink } = this.props; if (collection) { return ( - +
+ + +
); } 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))); diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js new file mode 100644 index 0000000000..65de6b2fa5 --- /dev/null +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -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 { + 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 ( + + ); + } + + if (!repository || loading) { + return + } + + const url = this.matchedUrl(); + + return +
+
+ } /> +
+
+ + + +
+
+
+ } +} + + +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)); diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index a70bdd7054..b23cb07a15 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -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); +} diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 1031e9efe0..e214e89520 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -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); + }); }); diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index c9120ba8ec..60f104a42d 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -50,9 +50,9 @@ class Users extends React.Component { /** * 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) {