diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index dc81c4a4fc..06ff997e2d 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -4,38 +4,58 @@ import { translate } from "react-i18next"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; type Props = { - t: string => string + t: string => string, + repositoriesLink: string, + usersLink: string, + groupsLink: string, + configLink: string, + logoutLink: string }; class PrimaryNavigation extends React.Component { render() { - const { t } = this.props; + const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props; + + const links = [ + repositoriesLink ? ( + ): null, + usersLink ? ( + ) : null, + groupsLink ? ( + ) : null, + configLink ? ( + ) : null, + logoutLink ? ( + ) : null + ]; + return ( ); diff --git a/scm-ui-components/packages/ui-types/src/IndexResources.js b/scm-ui-components/packages/ui-types/src/IndexResources.js new file mode 100644 index 0000000000..277ac6d5a8 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/IndexResources.js @@ -0,0 +1,7 @@ +//@flow +import type { Links } from "./hal"; + +export type IndexResources = { + version: string, + _links: Links +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index 948b98ffe5..883272b4d4 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -17,6 +17,8 @@ export type { Tag } from "./Tags"; export type { Config } from "./Config"; +export type { IndexResources } from "./IndexResources"; + export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { SubRepository, File } from "./Sources"; diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 44ba35ddae..60ee220318 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -73,29 +73,34 @@ "label": "Branches" }, "permission": { - "error-title": "Error", - "error-subtitle": "Unknown permissions error", - "name": "User or Group", - "type": "Type", - "group-permission": "Group Permission", - "edit-permission": { - "delete-button": "Delete", - "save-button": "Save Changes" - }, - "delete-permission-button": { - "label": "Delete", - "confirm-alert": { - "title": "Delete permission", - "message": "Do you really want to delete the permission?", - "submit": "Yes", - "cancel": "No" + "error-title": "Error", + "error-subtitle": "Unknown permissions error", + "name": "User or Group", + "type": "Type", + "group-permission": "Group Permission", + "edit-permission": { + "delete-button": "Delete", + "save-button": "Save Changes" + }, + "delete-permission-button": { + "label": "Delete", + "confirm-alert": { + "title": "Delete permission", + "message": "Do you really want to delete the permission?", + "submit": "Yes", + "cancel": "No" + } + }, + "add-permission": { + "add-permission-heading": "Add new Permission", + "submit-button": "Submit", + "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" + }, + "help": { + "groupPermissionHelpText": "States if a permission is a group permission.", + "nameHelpText": "Manage permissions for a specific user or group", + "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" } - }, - "add-permission": { - "add-permission-heading": "Add new Permission", - "submit-button": "Submit", - "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" - } }, "help": { "nameHelpText": "The name of the repository. This name will be part of the repository url.", diff --git a/scm-ui/src/config/containers/GlobalConfig.js b/scm-ui/src/config/containers/GlobalConfig.js index 09bdc4b7c5..252e880a42 100644 --- a/scm-ui/src/config/containers/GlobalConfig.js +++ b/scm-ui/src/config/containers/GlobalConfig.js @@ -14,18 +14,20 @@ import { modifyConfigReset } from "../modules/config"; import { connect } from "react-redux"; -import type { Config } from "@scm-manager/ui-types"; +import type { Config, Link } from "@scm-manager/ui-types"; import ConfigForm from "../components/form/ConfigForm"; +import { getConfigLink } from "../../modules/indexResource"; type Props = { loading: boolean, error: Error, config: Config, configUpdatePermission: boolean, + configLink: string, // dispatch functions modifyConfig: (config: Config, callback?: () => void) => void, - fetchConfig: void => void, + fetchConfig: (link: string) => void, configReset: void => void, // context objects @@ -35,7 +37,7 @@ type Props = { class GlobalConfig extends React.Component { componentDidMount() { this.props.configReset(); - this.props.fetchConfig(); + this.props.fetchConfig(this.props.configLink); } modifyConfig = (config: Config) => { @@ -75,8 +77,8 @@ class GlobalConfig extends React.Component { const mapDispatchToProps = dispatch => { return { - fetchConfig: () => { - dispatch(fetchConfig()); + fetchConfig: (link: string) => { + dispatch(fetchConfig(link)); }, modifyConfig: (config: Config, callback?: () => void) => { dispatch(modifyConfig(config, callback)); @@ -92,12 +94,14 @@ const mapStateToProps = state => { const error = getFetchConfigFailure(state) || getModifyConfigFailure(state); const config = getConfig(state); const configUpdatePermission = getConfigUpdatePermission(state); + const configLink = getConfigLink(state); return { loading, error, config, - configUpdatePermission + configUpdatePermission, + configLink }; }; diff --git a/scm-ui/src/config/modules/config.js b/scm-ui/src/config/modules/config.js index 02f55b346f..352afefb70 100644 --- a/scm-ui/src/config/modules/config.js +++ b/scm-ui/src/config/modules/config.js @@ -18,15 +18,14 @@ export const MODIFY_CONFIG_SUCCESS = `${MODIFY_CONFIG}_${types.SUCCESS_SUFFIX}`; export const MODIFY_CONFIG_FAILURE = `${MODIFY_CONFIG}_${types.FAILURE_SUFFIX}`; export const MODIFY_CONFIG_RESET = `${MODIFY_CONFIG}_${types.RESET_SUFFIX}`; -const CONFIG_URL = "config"; const CONTENT_TYPE_CONFIG = "application/vnd.scmm-config+json;v=2"; //fetch config -export function fetchConfig() { +export function fetchConfig(link: string) { return function(dispatch: any) { dispatch(fetchConfigPending()); return apiClient - .get(CONFIG_URL) + .get(link) .then(response => { return response.json(); }) diff --git a/scm-ui/src/config/modules/config.test.js b/scm-ui/src/config/modules/config.test.js index baff061a30..12c6b347c3 100644 --- a/scm-ui/src/config/modules/config.test.js +++ b/scm-ui/src/config/modules/config.test.js @@ -22,8 +22,10 @@ import reducer, { getConfig, getConfigUpdatePermission } from "./config"; +import { getConfigLink } from "../../modules/indexResource"; -const CONFIG_URL = "/api/v2/config"; +const CONFIG_URL = "/config"; +const URL = "/api/v2" + CONFIG_URL; const error = new Error("You have an error!"); @@ -103,7 +105,7 @@ describe("config fetch()", () => { }); it("should successfully fetch config", () => { - fetchMock.getOnce(CONFIG_URL, response); + fetchMock.getOnce(URL, response); const expectedActions = [ { type: FETCH_CONFIG_PENDING }, @@ -113,20 +115,36 @@ describe("config fetch()", () => { } ]; - const store = mockStore({}); + const store = mockStore({ + indexResources: { + links: { + config: { + href: CONFIG_URL + } + } + } + }); - return store.dispatch(fetchConfig()).then(() => { + return store.dispatch(fetchConfig(CONFIG_URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it("should fail getting config on HTTP 500", () => { - fetchMock.getOnce(CONFIG_URL, { + fetchMock.getOnce(URL, { status: 500 }); - const store = mockStore({}); - return store.dispatch(fetchConfig()).then(() => { + const store = mockStore({ + indexResources: { + links: { + config: { + href: CONFIG_URL + } + } + } + }); + return store.dispatch(fetchConfig(CONFIG_URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING); expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE); diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js index 8042611d01..768b1776d4 100644 --- a/scm-ui/src/containers/App.js +++ b/scm-ui/src/containers/App.js @@ -19,16 +19,33 @@ import { Footer, Header } from "@scm-manager/ui-components"; -import type { Me } from "@scm-manager/ui-types"; +import type { Me, Link } from "@scm-manager/ui-types"; +import { + fetchIndexResources, + getConfigLink, + getFetchIndexResourcesFailure, + getGroupsLink, + getLogoutLink, + getMeLink, + getRepositoriesLink, + getUsersLink, + isFetchIndexResourcesPending +} from "../modules/indexResource"; type Props = { me: Me, authenticated: boolean, error: Error, loading: boolean, + repositoriesLink: string, + usersLink: string, + groupsLink: string, + configLink: string, + logoutLink: string, + meLink: string, // dispatcher functions - fetchMe: () => void, + fetchMe: (link: string) => void, // context props t: string => string @@ -36,14 +53,37 @@ type Props = { class App extends Component { componentDidMount() { - this.props.fetchMe(); + if (this.props.meLink) { + this.props.fetchMe(this.props.meLink); + } } render() { - const { me, loading, error, authenticated, t } = this.props; + const { + me, + loading, + error, + authenticated, + t, + repositoriesLink, + usersLink, + groupsLink, + configLink, + logoutLink + } = this.props; let content; - const navigation = authenticated ? : ""; + const navigation = authenticated ? ( + + ) : ( + "" + ); if (loading) { content = ; @@ -70,20 +110,34 @@ class App extends Component { const mapDispatchToProps = (dispatch: any) => { return { - fetchMe: () => dispatch(fetchMe()) + fetchMe: (link: string) => dispatch(fetchMe(link)) }; }; const mapStateToProps = state => { const authenticated = isAuthenticated(state); const me = getMe(state); - const loading = isFetchMePending(state); - const error = getFetchMeFailure(state); + const loading = + isFetchMePending(state) || isFetchIndexResourcesPending(state); + const error = + getFetchMeFailure(state) || getFetchIndexResourcesFailure(state); + const repositoriesLink = getRepositoriesLink(state); + const usersLink = getUsersLink(state); + const groupsLink = getGroupsLink(state); + const configLink = getConfigLink(state); + const logoutLink = getLogoutLink(state); + const meLink = getMeLink(state); return { authenticated, me, loading, - error + error, + repositoriesLink, + usersLink, + groupsLink, + configLink, + logoutLink, + meLink }; }; diff --git a/scm-ui/src/containers/Index.js b/scm-ui/src/containers/Index.js new file mode 100644 index 0000000000..0fe6364f6e --- /dev/null +++ b/scm-ui/src/containers/Index.js @@ -0,0 +1,80 @@ +// @flow +import React, { Component } from "react"; +import App from "./App"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import { withRouter } from "react-router-dom"; + +import { Loading, ErrorPage } from "@scm-manager/ui-components"; +import { + fetchIndexResources, + getFetchIndexResourcesFailure, + getLinks, + isFetchIndexResourcesPending +} from "../modules/indexResource"; +import PluginLoader from "./PluginLoader"; +import type { IndexResources } from "@scm-manager/ui-types"; + +type Props = { + error: Error, + loading: boolean, + indexResources: IndexResources, + + // dispatcher functions + fetchIndexResources: () => void, + + // context props + t: string => string +}; + +class Index extends Component { + componentDidMount() { + this.props.fetchIndexResources(); + } + + render() { + const { indexResources, loading, error, t } = this.props; + + if (error) { + return ( + + ); + } else if (loading || !indexResources) { + return ; + } else { + return ( + + + + ); + } + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchIndexResources: () => dispatch(fetchIndexResources()) + }; +}; + +const mapStateToProps = state => { + const loading = isFetchIndexResourcesPending(state); + const error = getFetchIndexResourcesFailure(state); + const indexResources = getLinks(state); + return { + loading, + error, + indexResources + }; +}; + +export default withRouter( + connect( + mapStateToProps, + mapDispatchToProps + )(translate("commons")(Index)) +); diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 00e505e16d..8a06478045 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -18,6 +18,7 @@ import { Image } from "@scm-manager/ui-components"; import classNames from "classnames"; +import { getLoginLink } from "../modules/indexResource"; const styles = { avatar: { @@ -41,9 +42,10 @@ type Props = { authenticated: boolean, loading: boolean, error: Error, + link: string, // dispatcher props - login: (username: string, password: string) => void, + login: (link: string, username: string, password: string) => void, // context props t: string => string, @@ -74,7 +76,11 @@ class Login extends React.Component { handleSubmit = (event: Event) => { event.preventDefault(); if (this.isValid()) { - this.props.login(this.state.username, this.state.password); + this.props.login( + this.props.link, + this.state.username, + this.state.password + ); } }; @@ -145,17 +151,19 @@ const mapStateToProps = state => { const authenticated = isAuthenticated(state); const loading = isLoginPending(state); const error = getLoginFailure(state); + const link = getLoginLink(state); return { authenticated, loading, - error + error, + link }; }; const mapDispatchToProps = dispatch => { return { - login: (username: string, password: string) => - dispatch(login(username, password)) + login: (loginLink: string, username: string, password: string) => + dispatch(login(loginLink, username, password)) }; }; diff --git a/scm-ui/src/containers/Logout.js b/scm-ui/src/containers/Logout.js index 8d522a18bf..7875a6b92a 100644 --- a/scm-ui/src/containers/Logout.js +++ b/scm-ui/src/containers/Logout.js @@ -11,14 +11,16 @@ import { getLogoutFailure } from "../modules/auth"; import { Loading, ErrorPage } from "@scm-manager/ui-components"; +import { fetchIndexResources, getLogoutLink } from "../modules/indexResource"; type Props = { authenticated: boolean, loading: boolean, error: Error, + logoutLink: string, // dispatcher functions - logout: () => void, + logout: (link: string) => void, // context props t: string => string @@ -26,7 +28,7 @@ type Props = { class Logout extends React.Component { componentDidMount() { - this.props.logout(); + this.props.logout(this.props.logoutLink); } render() { @@ -51,16 +53,18 @@ const mapStateToProps = state => { const authenticated = isAuthenticated(state); const loading = isLogoutPending(state); const error = getLogoutFailure(state); + const logoutLink = getLogoutLink(state); return { authenticated, loading, - error + error, + logoutLink }; }; const mapDispatchToProps = dispatch => { return { - logout: () => dispatch(logout()) + logout: (link: string) => dispatch(logout(link)) }; }; diff --git a/scm-ui/src/containers/PluginLoader.js b/scm-ui/src/containers/PluginLoader.js index 16a5dd8d4d..8e44a1d427 100644 --- a/scm-ui/src/containers/PluginLoader.js +++ b/scm-ui/src/containers/PluginLoader.js @@ -1,9 +1,12 @@ // @flow import * as React from "react"; import { apiClient, Loading } from "@scm-manager/ui-components"; +import { getUiPluginsLink } from "../modules/indexResource"; +import { connect } from "react-redux"; type Props = { - children: React.Node + children: React.Node, + link: string }; type State = { @@ -29,8 +32,13 @@ class PluginLoader extends React.Component { this.setState({ message: "loading plugin information" }); - apiClient - .get("ui/plugins") + + this.getPlugins(this.props.link); + } + + getPlugins = (link: string): Promise => { + return apiClient + .get(link) .then(response => response.text()) .then(JSON.parse) .then(pluginCollection => pluginCollection._embedded.plugins) @@ -40,7 +48,7 @@ class PluginLoader extends React.Component { finished: true }); }); - } + }; loadPlugins = (plugins: Plugin[]) => { this.setState({ @@ -87,4 +95,11 @@ class PluginLoader extends React.Component { } } -export default PluginLoader; +const mapStateToProps = state => { + const link = getUiPluginsLink(state); + return { + link + }; +}; + +export default connect(mapStateToProps)(PluginLoader); diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index 8d3e016c80..bc519f3741 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -15,6 +15,7 @@ import pending from "./modules/pending"; import failure from "./modules/failure"; import permissions from "./repos/permissions/modules/permissions"; import config from "./config/modules/config"; +import indexResources from "./modules/indexResource"; import type { BrowserHistory } from "history/createBrowserHistory"; import branches from "./repos/modules/branches"; @@ -27,6 +28,7 @@ function createReduxStore(history: BrowserHistory) { router: routerReducer, pending, failure, + indexResources, users, repos, repositoryTypes, diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 0d3fc8ee2f..bcb19846b8 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -9,18 +9,21 @@ import { createGroup, isCreateGroupPending, getCreateGroupFailure, - createGroupReset + createGroupReset, + getCreateGroupLink } from "../modules/groups"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { t: string => string, - createGroup: (group: Group, callback?: () => void) => void, + createGroup: (link: string, group: Group, callback?: () => void) => void, history: History, loading?: boolean, error?: Error, - resetForm: () => void + resetForm: () => void, + createLink: string }; type State = {}; @@ -51,14 +54,14 @@ class AddGroup extends React.Component { this.props.history.push("/groups"); }; createGroup = (group: Group) => { - this.props.createGroup(group, this.groupCreated); + this.props.createGroup(this.props.createLink, group, this.groupCreated); }; } const mapDispatchToProps = dispatch => { return { - createGroup: (group: Group, callback?: () => void) => - dispatch(createGroup(group, callback)), + createGroup: (link: string, group: Group, callback?: () => void) => + dispatch(createGroup(link, group, callback)), resetForm: () => { dispatch(createGroupReset()); } @@ -68,7 +71,9 @@ const mapDispatchToProps = dispatch => { const mapStateToProps = state => { const loading = isCreateGroupPending(state); const error = getCreateGroupFailure(state); + const createLink = getGroupsLink(state); return { + createLink, loading, error }; diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 5fa1423f55..984055c60f 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -18,6 +18,7 @@ import { isPermittedToCreateGroups, selectListAsCollection } from "../modules/groups"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { groups: Group[], @@ -26,19 +27,20 @@ type Props = { canAddGroups: boolean, list: PagedCollection, page: number, + groupLink: string, // context objects t: string => string, history: History, // dispatch functions - fetchGroupsByPage: (page: number) => void, + fetchGroupsByPage: (link: string, page: number) => void, fetchGroupsByLink: (link: string) => void }; class Groups extends React.Component { componentDidMount() { - this.props.fetchGroupsByPage(this.props.page); + this.props.fetchGroupsByPage(this.props.groupLink, this.props.page); } onPageChange = (link: string) => { @@ -111,20 +113,23 @@ const mapStateToProps = (state, ownProps) => { const canAddGroups = isPermittedToCreateGroups(state); const list = selectListAsCollection(state); + const groupLink = getGroupsLink(state); + return { groups, loading, error, canAddGroups, list, - page + page, + groupLink }; }; const mapDispatchToProps = dispatch => { return { - fetchGroupsByPage: (page: number) => { - dispatch(fetchGroupsByPage(page)); + fetchGroupsByPage: (link: string, page: number) => { + dispatch(fetchGroupsByPage(link, page)); }, fetchGroupsByLink: (link: string) => { dispatch(fetchGroupsByLink(link)); diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index 9e0bcf74ef..d681859808 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -26,16 +26,18 @@ import { import { translate } from "react-i18next"; import EditGroup from "./EditGroup"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { name: string, group: Group, loading: boolean, error: Error, + groupLink: string, // dispatcher functions deleteGroup: (group: Group, callback?: () => void) => void, - fetchGroup: string => void, + fetchGroup: (string, string) => void, // context objects t: string => string, @@ -45,7 +47,7 @@ type Props = { class SingleGroup extends React.Component { componentDidMount() { - this.props.fetchGroup(this.props.name); + this.props.fetchGroup(this.props.groupLink, this.props.name); } stripEndingSlash = (url: string) => { @@ -132,19 +134,21 @@ const mapStateToProps = (state, ownProps) => { isFetchGroupPending(state, name) || isDeleteGroupPending(state, name); const error = getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name); + const groupLink = getGroupsLink(state); return { name, group, loading, - error + error, + groupLink }; }; const mapDispatchToProps = dispatch => { return { - fetchGroup: (name: string) => { - dispatch(fetchGroup(name)); + fetchGroup: (link: string, name: string) => { + dispatch(fetchGroup(link, name)); }, deleteGroup: (group: Group, callback?: () => void) => { dispatch(deleteGroup(group, callback)); diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 5ba9587260..74e7214052 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -32,17 +32,16 @@ export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`; export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`; export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`; -const GROUPS_URL = "groups"; const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2"; // fetch groups -export function fetchGroups() { - return fetchGroupsByLink(GROUPS_URL); +export function fetchGroups(link: string) { + return fetchGroupsByLink(link); } -export function fetchGroupsByPage(page: number) { +export function fetchGroupsByPage(link: string, page: number) { // backend start counting by 0 - return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1)); + return fetchGroupsByLink(link + "?page=" + (page - 1)); } export function fetchGroupsByLink(link: string) { @@ -56,7 +55,7 @@ export function fetchGroupsByLink(link: string) { }) .catch(cause => { const error = new Error(`could not fetch groups: ${cause.message}`); - dispatch(fetchGroupsFailure(GROUPS_URL, error)); + dispatch(fetchGroupsFailure(link, error)); }); }; } @@ -85,8 +84,8 @@ export function fetchGroupsFailure(url: string, error: Error): Action { } //fetch group -export function fetchGroup(name: string) { - const groupUrl = GROUPS_URL + "/" + name; +export function fetchGroup(link: string, name: string) { + const groupUrl = link.endsWith("/") ? link + name : link + "/" + name; return function(dispatch: any) { dispatch(fetchGroupPending(name)); return apiClient @@ -132,11 +131,11 @@ export function fetchGroupFailure(name: string, error: Error): Action { } //create group -export function createGroup(group: Group, callback?: () => void) { +export function createGroup(link: string, group: Group, callback?: () => void) { return function(dispatch: Dispatch) { dispatch(createGroupPending()); return apiClient - .post(GROUPS_URL, group, CONTENT_TYPE_GROUP) + .post(link, group, CONTENT_TYPE_GROUP) .then(() => { dispatch(createGroupSuccess()); if (callback) { @@ -410,6 +409,12 @@ export const isPermittedToCreateGroups = (state: Object): boolean => { return false; }; +export function getCreateGroupLink(state: Object) { + if (state.groups.list.entry && state.groups.list.entry._links) + return state.groups.list.entry._links.create.href; + return undefined; +} + export function getGroupsFromState(state: Object) { const groupNames = selectList(state).entries; if (!groupNames) { diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 191a2122e4..63ab375cd3 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -42,9 +42,11 @@ import reducer, { modifyGroup, MODIFY_GROUP_PENDING, MODIFY_GROUP_SUCCESS, - MODIFY_GROUP_FAILURE + MODIFY_GROUP_FAILURE, + getCreateGroupLink } from "./groups"; const GROUPS_URL = "/api/v2/groups"; +const URL = "/groups"; const error = new Error("You have an error!"); @@ -63,7 +65,7 @@ const humanGroup = { href: "http://localhost:8081/api/v2/groups/humanGroup" }, update: { - href:"http://localhost:8081/api/v2/groups/humanGroup" + href: "http://localhost:8081/api/v2/groups/humanGroup" } }, _embedded: { @@ -95,7 +97,7 @@ const emptyGroup = { href: "http://localhost:8081/api/v2/groups/emptyGroup" }, update: { - href:"http://localhost:8081/api/v2/groups/emptyGroup" + href: "http://localhost:8081/api/v2/groups/emptyGroup" } }, _embedded: { @@ -150,7 +152,7 @@ describe("groups fetch()", () => { const store = mockStore({}); - return store.dispatch(fetchGroups()).then(() => { + return store.dispatch(fetchGroups(URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -161,7 +163,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(fetchGroups()).then(() => { + return store.dispatch(fetchGroups(URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING); expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE); @@ -173,7 +175,7 @@ describe("groups fetch()", () => { fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup); const store = mockStore({}); - return store.dispatch(fetchGroup("humanGroup")).then(() => { + return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS); @@ -187,7 +189,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(fetchGroup("humanGroup")).then(() => { + return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE); @@ -195,14 +197,13 @@ describe("groups fetch()", () => { }); }); - it("should successfully create group", () => { fetchMock.postOnce(GROUPS_URL, { status: 201 }); const store = mockStore({}); - return store.dispatch(createGroup(humanGroup)).then(() => { + return store.dispatch(createGroup(URL, humanGroup)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); @@ -219,14 +220,13 @@ describe("groups fetch()", () => { called = true; }; const store = mockStore({}); - return store.dispatch(createGroup(humanGroup, callMe)).then(() => { + return store.dispatch(createGroup(URL, humanGroup, callMe)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); expect(called).toEqual(true); }); }); - it("should fail creating group on HTTP 500", () => { fetchMock.postOnce(GROUPS_URL, { @@ -234,7 +234,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(createGroup(humanGroup)).then(() => { + return store.dispatch(createGroup(URL, humanGroup)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE); @@ -248,7 +248,7 @@ describe("groups fetch()", () => { status: 204 }); - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup)).then(() => { const actions = store.getActions(); @@ -267,7 +267,7 @@ describe("groups fetch()", () => { const callback = () => { called = true; }; - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup, callback)).then(() => { const actions = store.getActions(); @@ -282,7 +282,7 @@ describe("groups fetch()", () => { status: 500 }); - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup)).then(() => { const actions = store.getActions(); @@ -337,13 +337,10 @@ describe("groups fetch()", () => { expect(actions[1].payload).toBeDefined(); }); }); - }); describe("groups reducer", () => { - it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => { - const newState = reducer({}, fetchGroupsSuccess(responseBody)); expect(newState.list).toEqual({ @@ -391,7 +388,6 @@ describe("groups reducer", () => { expect(newState.byNames["humanGroup"]).toBeTruthy(); }); - it("should update state according to FETCH_GROUP_SUCCESS action", () => { const newState = reducer({}, fetchGroupSuccess(emptyGroup)); expect(newState.byNames["emptyGroup"]).toBe(emptyGroup); @@ -426,7 +422,6 @@ describe("groups reducer", () => { expect(newState.byNames["emptyGroup"]).toBeFalsy(); expect(newState.list.entries).toEqual(["humanGroup"]); }); - }); describe("selector tests", () => { @@ -476,6 +471,23 @@ describe("selector tests", () => { expect(isPermittedToCreateGroups(state)).toBe(true); }); + it("should return create Group link", () => { + const state = { + groups: { + list: { + entry: { + _links: { + create: { + href: "/create" + } + } + } + } + } + }; + expect(getCreateGroupLink(state)).toBe("/create"); + }); + it("should get groups from state", () => { const state = { groups: { @@ -488,7 +500,7 @@ describe("selector tests", () => { } } }; - + expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]); }); @@ -560,9 +572,13 @@ describe("selector tests", () => { }); it("should return true if create group is pending", () => { - expect(isCreateGroupPending({pending: { - [CREATE_GROUP]: true - }})).toBeTruthy(); + expect( + isCreateGroupPending({ + pending: { + [CREATE_GROUP]: true + } + }) + ).toBeTruthy(); }); it("should return false if create group is not pending", () => { @@ -570,18 +586,19 @@ describe("selector tests", () => { }); it("should return error if creating group failed", () => { - expect(getCreateGroupFailure({ - failure: { - [CREATE_GROUP]: error - } - })).toEqual(error); + expect( + getCreateGroupFailure({ + failure: { + [CREATE_GROUP]: error + } + }) + ).toEqual(error); }); it("should return undefined if creating group did not fail", () => { expect(getCreateGroupFailure({})).toBeUndefined(); }); - it("should return true, when delete group humanGroup is pending", () => { const state = { pending: { diff --git a/scm-ui/src/index.js b/scm-ui/src/index.js index 511252620a..3ecd38e6d0 100644 --- a/scm-ui/src/index.js +++ b/scm-ui/src/index.js @@ -1,7 +1,7 @@ // @flow import React from "react"; import ReactDOM from "react-dom"; -import App from "./containers/App"; +import Index from "./containers/Index"; import registerServiceWorker from "./registerServiceWorker"; import { I18nextProvider } from "react-i18next"; @@ -37,9 +37,7 @@ ReactDOM.render( {/* ConnectedRouter will use the store from Provider automatically */} - - - + , diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js index 35dde975cc..fd5068aeb8 100644 --- a/scm-ui/src/modules/auth.js +++ b/scm-ui/src/modules/auth.js @@ -5,6 +5,12 @@ import * as types from "./types"; import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import { isPending } from "./pending"; import { getFailure } from "./failure"; +import { + callFetchIndexResources, + FETCH_INDEXRESOURCES_SUCCESS, + fetchIndexResources, fetchIndexResourcesPending, + fetchIndexResourcesSuccess +} from "./indexResource"; // Action @@ -121,16 +127,11 @@ export const fetchMeFailure = (error: Error) => { }; }; -// urls - -const ME_URL = "/me"; -const LOGIN_URL = "/auth/access_token"; - // side effects -const callFetchMe = (): Promise => { +const callFetchMe = (link: string): Promise => { return apiClient - .get(ME_URL) + .get(link) .then(response => { return response.json(); }) @@ -139,7 +140,11 @@ const callFetchMe = (): Promise => { }); }; -export const login = (username: string, password: string) => { +export const login = ( + loginLink: string, + username: string, + password: string +) => { const login_data = { cookie: true, grant_type: "password", @@ -149,9 +154,15 @@ export const login = (username: string, password: string) => { return function(dispatch: any) { dispatch(loginPending()); return apiClient - .post(LOGIN_URL, login_data) + .post(loginLink, login_data) .then(response => { - return callFetchMe(); + dispatch(fetchIndexResourcesPending()) + return callFetchIndexResources(); + }) + .then(response => { + dispatch(fetchIndexResourcesSuccess(response)); + const meLink = response._links.me.href; + return callFetchMe(meLink); }) .then(me => { dispatch(loginSuccess(me)); @@ -162,10 +173,10 @@ export const login = (username: string, password: string) => { }; }; -export const fetchMe = () => { +export const fetchMe = (link: string) => { return function(dispatch: any) { dispatch(fetchMePending()); - return callFetchMe() + return callFetchMe(link) .then(me => { dispatch(fetchMeSuccess(me)); }) @@ -179,14 +190,17 @@ export const fetchMe = () => { }; }; -export const logout = () => { +export const logout = (link: string) => { return function(dispatch: any) { dispatch(logoutPending()); return apiClient - .delete(LOGIN_URL) + .delete(link) .then(() => { dispatch(logoutSuccess()); }) + .then(() => { + dispatch(fetchIndexResources()); + }) .catch(error => { dispatch(logoutFailure(error)); }); diff --git a/scm-ui/src/modules/auth.test.js b/scm-ui/src/modules/auth.test.js index 3cea758566..1839701e0a 100644 --- a/scm-ui/src/modules/auth.test.js +++ b/scm-ui/src/modules/auth.test.js @@ -32,6 +32,10 @@ import reducer, { import configureMockStore from "redux-mock-store"; import thunk from "redux-thunk"; import fetchMock from "fetch-mock"; +import { + FETCH_INDEXRESOURCES_PENDING, + FETCH_INDEXRESOURCES_SUCCESS +} from "./indexResource"; const me = { name: "tricia", displayName: "Tricia McMillian" }; @@ -93,16 +97,30 @@ describe("auth actions", () => { headers: { "content-type": "application/json" } }); + const meLink = { + me: { + href: "/me" + } + }; + + fetchMock.getOnce("/api/v2/", { + _links: meLink + }); + const expectedActions = [ { type: LOGIN_PENDING }, + { type: FETCH_INDEXRESOURCES_PENDING }, + { type: FETCH_INDEXRESOURCES_SUCCESS, payload: { _links: meLink } }, { type: LOGIN_SUCCESS, payload: me } ]; const store = mockStore({}); - return store.dispatch(login("tricia", "secret123")).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + return store + .dispatch(login("/auth/access_token", "tricia", "secret123")) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); }); it("should dispatch login failure", () => { @@ -111,12 +129,14 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(login("tricia", "secret123")).then(() => { - const actions = store.getActions(); - expect(actions[0].type).toEqual(LOGIN_PENDING); - expect(actions[1].type).toEqual(LOGIN_FAILURE); - expect(actions[1].payload).toBeDefined(); - }); + return store + .dispatch(login("/auth/access_token", "tricia", "secret123")) + .then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(LOGIN_PENDING); + expect(actions[1].type).toEqual(LOGIN_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); }); it("should dispatch fetch me success", () => { @@ -135,7 +155,7 @@ describe("auth actions", () => { const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -146,7 +166,7 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_ME_PENDING); expect(actions[1].type).toEqual(FETCH_ME_FAILURE); @@ -166,7 +186,7 @@ describe("auth actions", () => { const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); @@ -181,14 +201,23 @@ describe("auth actions", () => { status: 401 }); + fetchMock.getOnce("/api/v2/", { + _links: { + login: { + login: "/login" + } + } + }); + const expectedActions = [ { type: LOGOUT_PENDING }, - { type: LOGOUT_SUCCESS } + { type: LOGOUT_SUCCESS }, + { type: FETCH_INDEXRESOURCES_PENDING } ]; const store = mockStore({}); - return store.dispatch(logout()).then(() => { + return store.dispatch(logout("/auth/access_token")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -199,7 +228,7 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(logout()).then(() => { + return store.dispatch(logout("/auth/access_token")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(LOGOUT_PENDING); expect(actions[1].type).toEqual(LOGOUT_FAILURE); diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js new file mode 100644 index 0000000000..98dd9848dc --- /dev/null +++ b/scm-ui/src/modules/indexResource.js @@ -0,0 +1,145 @@ +// @flow +import * as types from "./types"; + +import { apiClient } from "@scm-manager/ui-components"; +import type { Action, IndexResources } from "@scm-manager/ui-types"; +import { isPending } from "./pending"; +import { getFailure } from "./failure"; + +// Action + +export const FETCH_INDEXRESOURCES = "scm/INDEXRESOURCES"; +export const FETCH_INDEXRESOURCES_PENDING = `${FETCH_INDEXRESOURCES}_${ + types.PENDING_SUFFIX +}`; +export const FETCH_INDEXRESOURCES_SUCCESS = `${FETCH_INDEXRESOURCES}_${ + types.SUCCESS_SUFFIX +}`; +export const FETCH_INDEXRESOURCES_FAILURE = `${FETCH_INDEXRESOURCES}_${ + types.FAILURE_SUFFIX +}`; + +const INDEX_RESOURCES_LINK = "/"; + +export const callFetchIndexResources = (): Promise => { + return apiClient.get(INDEX_RESOURCES_LINK).then(response => { + return response.json(); + }); +}; + +export function fetchIndexResources() { + return function(dispatch: any) { + dispatch(fetchIndexResourcesPending()); + return callFetchIndexResources() + .then(resources => { + dispatch(fetchIndexResourcesSuccess(resources)); + }) + .catch(err => { + dispatch(fetchIndexResourcesFailure(err)); + }); + }; +} + +export function fetchIndexResourcesPending(): Action { + return { + type: FETCH_INDEXRESOURCES_PENDING + }; +} + +export function fetchIndexResourcesSuccess(resources: IndexResources): Action { + return { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: resources + }; +} + +export function fetchIndexResourcesFailure(err: Error): Action { + return { + type: FETCH_INDEXRESOURCES_FAILURE, + payload: err + }; +} + +// reducer +export default function reducer( + state: Object = {}, + action: Action = { type: "UNKNOWN" } +): Object { + if (!action.payload) { + return state; + } + + switch (action.type) { + case FETCH_INDEXRESOURCES_SUCCESS: + return { + ...state, + links: action.payload._links + }; + default: + return state; + } +} + +// selectors + +export function isFetchIndexResourcesPending(state: Object) { + return isPending(state, FETCH_INDEXRESOURCES); +} + +export function getFetchIndexResourcesFailure(state: Object) { + return getFailure(state, FETCH_INDEXRESOURCES); +} + +export function getLinks(state: Object) { + return state.indexResources.links; +} + +export function getLink(state: Object, name: string) { + if (state.indexResources.links && state.indexResources.links[name]) { + return state.indexResources.links[name].href; + } +} + +export function getUiPluginsLink(state: Object) { + return getLink(state, "uiPlugins"); +} + +export function getMeLink(state: Object) { + return getLink(state, "me"); +} + +export function getLogoutLink(state: Object) { + return getLink(state, "logout"); +} + +export function getLoginLink(state: Object) { + return getLink(state, "login"); +} + +export function getUsersLink(state: Object) { + return getLink(state, "users"); +} + +export function getGroupsLink(state: Object) { + return getLink(state, "groups"); +} + +export function getConfigLink(state: Object) { + return getLink(state, "config"); +} + +export function getRepositoriesLink(state: Object) { + return getLink(state, "repositories"); +} + +export function getHgConfigLink(state: Object) { + return getLink(state, "hgConfig"); +} + +export function getGitConfigLink(state: Object) { + return getLink(state, "gitConfig"); +} + +export function getSvnConfigLink(state: Object) { + return getLink(state, "svnConfig"); +} diff --git a/scm-ui/src/modules/indexResource.test.js b/scm-ui/src/modules/indexResource.test.js new file mode 100644 index 0000000000..2199da8290 --- /dev/null +++ b/scm-ui/src/modules/indexResource.test.js @@ -0,0 +1,426 @@ +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import fetchMock from "fetch-mock"; +import reducer, { + FETCH_INDEXRESOURCES_PENDING, + FETCH_INDEXRESOURCES_SUCCESS, + FETCH_INDEXRESOURCES_FAILURE, + fetchIndexResources, + fetchIndexResourcesSuccess, + FETCH_INDEXRESOURCES, + isFetchIndexResourcesPending, + getFetchIndexResourcesFailure, + getUiPluginsLink, + getMeLink, + getLogoutLink, + getLoginLink, + getUsersLink, + getConfigLink, + getRepositoriesLink, + getHgConfigLink, + getGitConfigLink, + getSvnConfigLink, + getLinks, getGroupsLink +} from "./indexResource"; + +const indexResourcesUnauthenticated = { + version: "2.0.0-SNAPSHOT", + _links: { + self: { + href: "http://localhost:8081/scm/api/v2/" + }, + uiPlugins: { + href: "http://localhost:8081/scm/api/v2/ui/plugins" + }, + login: { + href: "http://localhost:8081/scm/api/v2/auth/access_token" + } + } +}; + +const indexResourcesAuthenticated = { + version: "2.0.0-SNAPSHOT", + _links: { + self: { + href: "http://localhost:8081/scm/api/v2/" + }, + uiPlugins: { + href: "http://localhost:8081/scm/api/v2/ui/plugins" + }, + me: { + href: "http://localhost:8081/scm/api/v2/me/" + }, + logout: { + href: "http://localhost:8081/scm/api/v2/auth/access_token" + }, + users: { + href: "http://localhost:8081/scm/api/v2/users/" + }, + groups: { + href: "http://localhost:8081/scm/api/v2/groups/" + }, + config: { + href: "http://localhost:8081/scm/api/v2/config" + }, + repositories: { + href: "http://localhost:8081/scm/api/v2/repositories/" + }, + hgConfig: { + href: "http://localhost:8081/scm/api/v2/config/hg" + }, + gitConfig: { + href: "http://localhost:8081/scm/api/v2/config/git" + }, + svnConfig: { + href: "http://localhost:8081/scm/api/v2/config/svn" + } + } +}; + +describe("fetch index resource", () => { + const index_url = "/api/v2/"; + const mockStore = configureMockStore([thunk]); + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should successfully fetch index resources when unauthenticated", () => { + fetchMock.getOnce(index_url, indexResourcesUnauthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesUnauthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch index resources when authenticated", () => { + fetchMock.getOnce(index_url, indexResourcesAuthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesAuthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { + fetchMock.getOnce(index_url, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); + expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); + }); +}); + +describe("index resources reducer", () => { + it("should return empty object, if state and action is undefined", () => { + expect(reducer()).toEqual({}); + }); + + it("should return the same state, if the action is undefined", () => { + const state = { x: true }; + expect(reducer(state)).toBe(state); + }); + + it("should return the same state, if the action is unknown to the reducer", () => { + const state = { x: true }; + expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); + }); + + it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { + const newState = reducer( + {}, + fetchIndexResourcesSuccess(indexResourcesAuthenticated) + ); + expect(newState.links).toBe(indexResourcesAuthenticated._links); + }); +}); + +describe("index resources selectors", () => { + const error = new Error("something goes wrong"); + + it("should return true, when fetch index resources is pending", () => { + const state = { + pending: { + [FETCH_INDEXRESOURCES]: true + } + }; + expect(isFetchIndexResourcesPending(state)).toEqual(true); + }); + + it("should return false, when fetch index resources is not pending", () => { + expect(isFetchIndexResourcesPending({})).toEqual(false); + }); + + it("should return error when fetch index resources did fail", () => { + const state = { + failure: { + [FETCH_INDEXRESOURCES]: error + } + }; + expect(getFetchIndexResourcesFailure(state)).toEqual(error); + }); + + it("should return undefined when fetch index resources did not fail", () => { + expect(getFetchIndexResourcesFailure({})).toBe(undefined); + }); + + it("should return all links", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); + }); + + // ui plugins link + it("should return ui plugins link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + it("should return ui plugins links when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + // me link + it("should return me link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); + }); + + it("should return undefined for me link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getMeLink(state)).toBe(undefined); + }); + + // logout link + it("should return logout link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLogoutLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLogoutLink(state)).toBe(undefined); + }); + + // login link + it("should return login link when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLoginLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for login link when authenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLoginLink(state)).toBe(undefined); + }); + + // users link + it("should return users link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/"); + }); + + it("should return undefined for users link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUsersLink(state)).toBe(undefined); + }); + + // groups link + it("should return groups link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/"); + }); + + it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGroupsLink(state)).toBe(undefined); + }); + + // config link + it("should return config link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config" + ); + }); + + it("should return undefined for config link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getConfigLink(state)).toBe(undefined); + }); + + // repositories link + it("should return repositories link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe( + "http://localhost:8081/scm/api/v2/repositories/" + ); + }); + + it("should return config for repositories link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe(undefined); + }); + + // hgConfig link + it("should return hgConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/hg" + ); + }); + + it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe(undefined); + }); + + // gitConfig link + it("should return gitConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/git" + ); + }); + + it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe(undefined); + }); + + // svnConfig link + it("should return svnConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/svn" + ); + }); + + it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe(undefined); + }); +}); diff --git a/scm-ui/src/repos/containers/Create.js b/scm-ui/src/repos/containers/Create.js index ed1de39b75..4cf8d468de 100644 --- a/scm-ui/src/repos/containers/Create.js +++ b/scm-ui/src/repos/containers/Create.js @@ -18,16 +18,18 @@ import { isCreateRepoPending } from "../modules/repos"; import type { History } from "history"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { repositoryTypes: RepositoryType[], typesLoading: boolean, createLoading: boolean, error: Error, + repoLink: string, // dispatch functions fetchRepositoryTypesIfNeeded: () => void, - createRepo: (Repository, callback: () => void) => void, + createRepo: (link: string, Repository, callback: () => void) => void, resetForm: () => void, // context props @@ -55,7 +57,7 @@ class Create extends React.Component { error } = this.props; - const { t } = this.props; + const { t, repoLink } = this.props; return ( { repositoryTypes={repositoryTypes} loading={createLoading} submitForm={repo => { - createRepo(repo, this.repoCreated); + createRepo(repoLink, repo, this.repoCreated); }} /> @@ -82,11 +84,13 @@ const mapStateToProps = state => { const createLoading = isCreateRepoPending(state); const error = getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state); + const repoLink = getRepositoriesLink(state); return { repositoryTypes, typesLoading, createLoading, - error + error, + repoLink }; }; @@ -95,8 +99,12 @@ const mapDispatchToProps = dispatch => { fetchRepositoryTypesIfNeeded: () => { dispatch(fetchRepositoryTypesIfNeeded()); }, - createRepo: (repository: Repository, callback: () => void) => { - dispatch(createRepo(repository, callback)); + createRepo: ( + link: string, + repository: Repository, + callback: () => void + ) => { + dispatch(createRepo(link, repository, callback)); }, resetForm: () => { dispatch(createRepoReset()); diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js index 10230b29da..bbafe14539 100644 --- a/scm-ui/src/repos/containers/Overview.js +++ b/scm-ui/src/repos/containers/Overview.js @@ -18,6 +18,7 @@ import { CreateButton, Page, Paginator } from "@scm-manager/ui-components"; import RepositoryList from "../components/list"; import { withRouter } from "react-router-dom"; import type { History } from "history"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { page: number, @@ -25,10 +26,11 @@ type Props = { loading: boolean, error: Error, showCreateButton: boolean, + reposLink: string, // dispatched functions - fetchRepos: () => void, - fetchReposByPage: number => void, + fetchRepos: string => void, + fetchReposByPage: (string, number) => void, fetchReposByLink: string => void, // context props @@ -38,7 +40,7 @@ type Props = { class Overview extends React.Component { componentDidMount() { - this.props.fetchReposByPage(this.props.page); + this.props.fetchReposByPage(this.props.reposLink, this.props.page); } /** @@ -113,7 +115,9 @@ const mapStateToProps = (state, ownProps) => { const loading = isFetchReposPending(state); const error = getFetchReposFailure(state); const showCreateButton = isAbleToCreateRepos(state); + const reposLink = getRepositoriesLink(state); return { + reposLink, page, collection, loading, @@ -124,11 +128,11 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - fetchRepos: () => { - dispatch(fetchRepos()); + fetchRepos: (link: string) => { + dispatch(fetchRepos(link)); }, - fetchReposByPage: (page: number) => { - dispatch(fetchReposByPage(page)); + fetchReposByPage: (link: string, page: number) => { + dispatch(fetchReposByPage(link, page)); }, fetchReposByLink: (link: string) => { dispatch(fetchReposByLink(link)); diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 9475612765..9e6ada9a4e 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -34,6 +34,7 @@ import ChangesetView from "./ChangesetView"; import PermissionsNavLink from "../components/PermissionsNavLink"; import Sources from "../sources/containers/Sources"; import RepositoryNavLink from "../components/RepositoryNavLink"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { namespace: string, @@ -41,9 +42,10 @@ type Props = { repository: Repository, loading: boolean, error: Error, + repoLink: string, // dispatch functions - fetchRepo: (namespace: string, name: string) => void, + fetchRepo: (link: string, namespace: string, name: string) => void, deleteRepo: (repository: Repository, () => void) => void, // context props @@ -54,9 +56,9 @@ type Props = { class RepositoryRoot extends React.Component { componentDidMount() { - const { fetchRepo, namespace, name } = this.props; + const { fetchRepo, namespace, name, repoLink } = this.props; - fetchRepo(namespace, name); + fetchRepo(repoLink, namespace, name); } stripEndingSlash = (url: string) => { @@ -212,19 +214,21 @@ const mapStateToProps = (state, ownProps) => { const repository = getRepository(state, namespace, name); const loading = isFetchRepoPending(state, namespace, name); const error = getFetchRepoFailure(state, namespace, name); + const repoLink = getRepositoriesLink(state); return { namespace, name, repository, loading, - error + error, + repoLink }; }; const mapDispatchToProps = dispatch => { return { - fetchRepo: (namespace: string, name: string) => { - dispatch(fetchRepo(namespace, name)); + fetchRepo: (link: string, namespace: string, name: string) => { + dispatch(fetchRepo(link, namespace, name)); }, deleteRepo: (repository: Repository, callback: () => void) => { dispatch(deleteRepo(repository, callback)); diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index 1fb769f851..b5016bbb43 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -35,20 +35,18 @@ export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`; -const REPOS_URL = "repositories"; - const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2"; // fetch repos const SORT_BY = "sortBy=namespaceAndName"; -export function fetchRepos() { - return fetchReposByLink(REPOS_URL); +export function fetchRepos(link: string) { + return fetchReposByLink(link); } -export function fetchReposByPage(page: number) { - return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`); +export function fetchReposByPage(link: string, page: number) { + return fetchReposByLink(`${link}?page=${page - 1}`); } function appendSortByLink(url: string) { @@ -102,11 +100,12 @@ export function fetchReposFailure(err: Error): Action { // fetch repo -export function fetchRepo(namespace: string, name: string) { +export function fetchRepo(link: string, namespace: string, name: string) { + const repoUrl = link.endsWith("/") ? link : link + "/"; return function(dispatch: any) { dispatch(fetchRepoPending(namespace, name)); return apiClient - .get(`${REPOS_URL}/${namespace}/${name}`) + .get(`${repoUrl}${namespace}/${name}`) .then(response => response.json()) .then(repository => { dispatch(fetchRepoSuccess(repository)); @@ -154,11 +153,15 @@ export function fetchRepoFailure( // create repo -export function createRepo(repository: Repository, callback?: () => void) { +export function createRepo( + link: string, + repository: Repository, + callback?: () => void +) { return function(dispatch: any) { dispatch(createRepoPending()); return apiClient - .post(REPOS_URL, repository, CONTENT_TYPE) + .post(link, repository, CONTENT_TYPE) .then(() => { dispatch(createRepoSuccess()); if (callback) { @@ -448,3 +451,12 @@ export function getDeleteRepoFailure( ) { return getFailure(state, DELETE_REPO, namespace + "/" + name); } + +export function getPermissionsLink( + state: Object, + namespace: string, + name: string +) { + const repo = getRepository(state, namespace, name); + return repo && repo._links ? repo._links.permissions.href : undefined; +} diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 302918f02e..5b5c2d3abd 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -45,7 +45,8 @@ import reducer, { MODIFY_REPO, isModifyRepoPending, getModifyRepoFailure, - modifyRepoSuccess + modifyRepoSuccess, + getPermissionsLink } from "./repos"; import type { Repository, RepositoryCollection } from "@scm-manager/ui-types"; @@ -99,16 +100,13 @@ const hitchhikerRestatend: Repository = { type: "git", _links: { self: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, delete: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, update: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, permissions: { href: @@ -158,16 +156,14 @@ const slartiFjords: Repository = { href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/" }, branches: { - href: - "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/" + href: "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/" }, changesets: { href: "http://localhost:8081/api/v2/repositories/slarti/fjords/changesets/" }, sources: { - href: - "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/" + href: "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/" } } }; @@ -221,6 +217,7 @@ const repositoryCollectionWithNames: RepositoryCollection = { }; describe("repos fetch", () => { + const URL = "repositories"; const REPOS_URL = "/api/v2/repositories"; const SORT = "sortBy=namespaceAndName"; const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT; @@ -243,7 +240,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(fetchRepos()).then(() => { + return store.dispatch(fetchRepos(URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -262,7 +259,7 @@ describe("repos fetch", () => { const store = mockStore({}); - return store.dispatch(fetchReposByPage(43)).then(() => { + return store.dispatch(fetchReposByPage(URL, 43)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -318,7 +315,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(fetchRepos()).then(() => { + return store.dispatch(fetchRepos(URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_REPOS_PENDING); expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE); @@ -346,7 +343,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { + return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -357,7 +354,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { + return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_REPO_PENDING); expect(actions[1].type).toEqual(FETCH_REPO_FAILURE); @@ -383,7 +380,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -400,7 +397,7 @@ describe("repos fetch", () => { }; const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords, callback)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => { expect(callMe).toBe("yeah"); }); }); @@ -411,7 +408,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_REPO_PENDING); expect(actions[1].type).toEqual(CREATE_REPO_FAILURE); @@ -649,6 +646,21 @@ describe("repos selectors", () => { expect(repository).toEqual(slartiFjords); }); + it("should return permissions link", () => { + const state = { + repos: { + byNames: { + "slarti/fjords": slartiFjords + } + } + }; + + const link = getPermissionsLink(state, "slarti", "fjords"); + expect(link).toEqual( + "http://localhost:8081/api/v2/repositories/slarti/fjords/permissions/" + ); + }); + it("should return true, when fetch repo is pending", () => { const state = { pending: { diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js index 5268de7e9d..0bc42fac9f 100644 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js @@ -51,14 +51,17 @@ class CreatePermissionForm extends React.Component { onChange={this.handleNameChange} validationError={!this.state.valid} errorMessage={t("permission.add-permission.name-input-invalid")} + helpText={t("permission.help.nameHelpText")} /> diff --git a/scm-ui/src/repos/permissions/components/TypeSelector.js b/scm-ui/src/repos/permissions/components/TypeSelector.js index 89319581d0..de1950fa78 100644 --- a/scm-ui/src/repos/permissions/components/TypeSelector.js +++ b/scm-ui/src/repos/permissions/components/TypeSelector.js @@ -7,13 +7,15 @@ type Props = { t: string => string, handleTypeChange: string => void, type: string, + label?: string, + helpText?: string, loading?: boolean }; class TypeSelector extends React.Component { render() { - const { type, handleTypeChange, loading } = this.props; - const types = ["READ", "WRITE", "OWNER"]; + const { type, handleTypeChange, loading, label, helpText } = this.props; + const types = ["READ", "OWNER", "WRITE"]; return (