scm-ui: new repository layout

This commit is contained in:
Sebastian Sdorra
2019-10-07 10:57:09 +02:00
parent 09c7def874
commit c05798e254
417 changed files with 3620 additions and 52971 deletions

View File

@@ -0,0 +1,45 @@
//@flow
import React from "react";
import type { Repository, Branch } from "@scm-manager/ui-types";
import { ButtonAddons, Button } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
branch: Branch,
// context props
t: string => string
};
class BranchButtonGroup extends React.Component<Props> {
render() {
const { repository, branch, t } = this.props;
const changesetLink = `/repo/${repository.namespace}/${
repository.name
}/branch/${encodeURIComponent(branch.name)}/changesets/`;
const sourcesLink = `/repo/${repository.namespace}/${
repository.name
}/sources/${encodeURIComponent(branch.name)}/`;
return (
<ButtonAddons>
<Button
link={changesetLink}
icon="exchange-alt"
label={t("branch.commits")}
reducedMobile={true}
/>
<Button
link={sourcesLink}
icon="code"
label={t("branch.sources")}
reducedMobile={true}
/>
</ButtonAddons>
);
}
}
export default translate("repos")(BranchButtonGroup);

View File

@@ -0,0 +1,33 @@
//@flow
import React from "react";
import type { Repository, Branch } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import BranchButtonGroup from "./BranchButtonGroup";
import DefaultBranchTag from "./DefaultBranchTag";
type Props = {
repository: Repository,
branch: Branch,
// context props
t: string => string
};
class BranchDetail extends React.Component<Props> {
render() {
const { repository, branch, t } = this.props;
return (
<div className="media">
<div className="media-content subtitle">
<strong>{t("branch.name")}</strong> {branch.name}{" "}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</div>
<div className="media-right">
<BranchButtonGroup repository={repository} branch={branch} />
</div>
</div>
);
}
}
export default translate("repos")(BranchDetail);

View File

@@ -0,0 +1,125 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
import {
Select,
InputField,
SubmitButton,
validation as validator
} from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
type Props = {
submitForm: BranchRequest => void,
repository: Repository,
branches: Branch[],
loading?: boolean,
transmittedName?: string,
disabled?: boolean,
t: string => string
};
type State = {
source?: string,
name?: string,
nameValidationError: boolean
};
class BranchForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
nameValidationError: false,
name: props.transmittedName
};
}
isFalsy(value) {
return !value;
}
isValid = () => {
const { source, name } = this.state;
return !(
this.state.nameValidationError ||
this.isFalsy(source) ||
this.isFalsy(name)
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm({
name: this.state.name,
parent: this.state.source
});
}
};
render() {
const { t, branches, loading, transmittedName, disabled } = this.props;
const { name } = this.state;
orderBranches(branches);
const options = branches.map(branch => ({
label: branch.name,
value: branch.name
}));
return (
<>
<form onSubmit={this.submit}>
<div className="columns">
<div className="column">
<Select
name="source"
label={t("branches.create.source")}
options={options}
onChange={this.handleSourceChange}
loading={loading}
disabled={disabled}
/>
<InputField
name="name"
label={t("branches.create.name")}
onChange={this.handleNameChange}
value={name ? name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.branch.nameInvalid")}
disabled={!!transmittedName || disabled}
/>
</div>
</div>
<div className="columns">
<div className="column">
<SubmitButton
disabled={disabled || !this.isValid()}
loading={loading}
label={t("branches.create.submit")}
/>
</div>
</div>
</form>
</>
);
}
handleSourceChange = (source: string) => {
this.setState({
...this.state,
source
});
};
handleNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
...this.state,
name
});
};
}
export default translate("repos")(BranchForm);

View File

@@ -0,0 +1,32 @@
// @flow
import React from "react";
import { Link } from "react-router-dom";
import type { Branch } from "@scm-manager/ui-types";
import DefaultBranchTag from "./DefaultBranchTag";
type Props = {
baseUrl: string,
branch: Branch
};
class BranchRow extends React.Component<Props> {
renderLink(to: string, label: string, defaultBranch?: boolean) {
return (
<Link to={to}>
{label} <DefaultBranchTag defaultBranch={defaultBranch} />
</Link>
);
}
render() {
const { baseUrl, branch } = this.props;
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
return (
<tr>
<td>{this.renderLink(to, branch.name, branch.defaultBranch)}</td>
</tr>
);
}
}
export default BranchRow;

View File

@@ -0,0 +1,40 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import BranchRow from "./BranchRow";
import type { Branch } from "@scm-manager/ui-types";
type Props = {
baseUrl: string,
t: string => string,
branches: Branch[]
};
class BranchTable extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("branches.table.branches")}</th>
</tr>
</thead>
<tbody>{this.renderRow()}</tbody>
</table>
);
}
renderRow() {
const { baseUrl, branches } = this.props;
let rowContent = null;
if (branches) {
rowContent = branches.map((branch, index) => {
return <BranchRow key={index} baseUrl={baseUrl} branch={branch} />;
});
}
return rowContent;
}
}
export default translate("repos")(BranchTable);

View File

@@ -0,0 +1,32 @@
// @flow
import React from "react";
import BranchDetail from "./BranchDetail";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository, Branch } from "@scm-manager/ui-types";
type Props = {
repository: Repository,
branch: Branch
};
class BranchView extends React.Component<Props> {
render() {
const { repository, branch } = this.props;
return (
<div>
<BranchDetail repository={repository} branch={branch} />
<hr />
<div className="content">
<ExtensionPoint
name="repos.branch-details.information"
renderAll={true}
props={{ repository, branch }}
/>
</div>
</div>
);
}
}
export default BranchView;

View File

@@ -0,0 +1,39 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import { Tag } from "@scm-manager/ui-components";
type Props = {
defaultBranch?: boolean,
// context props
classes: any,
t: string => string
};
const styles = {
tag: {
marginLeft: "0.75rem",
verticalAlign: "inherit"
}
};
class DefaultBranchTag extends React.Component<Props> {
render() {
const { defaultBranch, classes, t } = this.props;
if (defaultBranch) {
return (
<Tag
className={classes.tag}
color="dark"
label={t("branch.defaultTag")}
/>
);
}
return null;
}
}
export default injectSheet(styles)(translate("repos")(DefaultBranchTag));

View File

@@ -0,0 +1,120 @@
//@flow
import React from "react";
import BranchView from "../components/BranchView";
import { connect } from "react-redux";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import type { Repository, Branch } from "@scm-manager/ui-types";
import {
fetchBranch,
getBranch,
getFetchBranchFailure,
isFetchBranchPending
} from "../modules/branches";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import type { History } from "history";
import { NotFoundError } from "@scm-manager/ui-components";
import queryString from "query-string";
type Props = {
repository: Repository,
branchName: string,
branch: Branch,
loading: boolean,
error?: Error,
// context props
history: History,
match: any,
location: any,
// dispatch functions
fetchBranch: (repository: Repository, branchName: string) => void
};
class BranchRoot extends React.Component<Props> {
componentDidMount() {
const { fetchBranch, repository, branchName } = this.props;
fetchBranch(repository, branchName);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { repository, branch, loading, error, match, location } = this.props;
const url = this.matchedUrl();
if (error) {
if (
error instanceof NotFoundError &&
queryString.parse(location.search).create === "true"
) {
return (
<Redirect
to={`/repo/${repository.namespace}/${
repository.name
}/branches/create?name=${match.params.branch}`}
/>
);
}
return <ErrorNotification error={error} />;
}
if (loading || !branch) {
return <Loading />;
}
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route
path={`${url}/info`}
component={() => (
<BranchView repository={repository} branch={branch} />
)}
/>
</Switch>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const branchName = decodeURIComponent(ownProps.match.params.branch);
const branch = getBranch(state, repository, branchName);
const loading = isFetchBranchPending(state, repository, branchName);
const error = getFetchBranchFailure(state, repository, branchName);
return {
repository,
branchName,
branch,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchBranch: (repository: Repository, branchName: string) => {
dispatch(fetchBranch(repository, branchName));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(BranchRoot)
);

View File

@@ -0,0 +1,122 @@
// @flow
import React from "react";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending,
isPermittedToCreateBranches
} from "../modules/branches";
import { orderBranches } from "../util/orderBranches";
import { connect } from "react-redux";
import type { Branch, Repository } from "@scm-manager/ui-types";
import { compose } from "redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import {
CreateButton,
ErrorNotification,
Loading,
Notification,
Subtitle
} from "@scm-manager/ui-components";
import BranchTable from "../components/BranchTable";
type Props = {
repository: Repository,
baseUrl: string,
loading: boolean,
error: Error,
branches: Branch[],
// dispatch props
showCreateButton: boolean,
fetchBranches: Repository => void,
// Context props
history: any,
match: any,
t: string => string
};
class BranchesOverview extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
}
render() {
const { loading, error, branches, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!branches || loading) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{this.renderBranchesTable()}
{this.renderCreateButton()}
</>
);
}
renderBranchesTable() {
const { baseUrl, branches, t } = this.props;
if (branches && branches.length > 0) {
orderBranches(branches);
return <BranchTable baseUrl={baseUrl} branches={branches} />;
}
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
renderCreateButton() {
const { showCreateButton, t } = this.props;
if (showCreateButton) {
return (
<CreateButton
label={t("branches.overview.createButton")}
link="./create"
/>
);
}
return null;
}
}
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const showCreateButton = isPermittedToCreateBranches(state, repository);
return {
repository,
loading,
error,
branches,
showCreateButton
};
};
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
}
};
};
export default compose(
translate("repos"),
withRouter,
connect(
mapStateToProps,
mapDispatchToProps
)
)(BranchesOverview);

View File

@@ -0,0 +1,150 @@
//@flow
import React from "react";
import {
ErrorNotification,
Loading,
Subtitle
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import BranchForm from "../components/BranchForm";
import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
import {
fetchBranches,
getBranches,
getBranchCreateLink,
createBranch,
createBranchReset,
isCreateBranchPending,
getCreateBranchFailure,
isFetchBranchesPending,
getFetchBranchesFailure
} from "../modules/branches";
import type { History } from "history";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import queryString from "query-string";
type Props = {
loading?: boolean,
error?: Error,
repository: Repository,
branches: Branch[],
createBranchesLink: string,
isPermittedToCreateBranches: boolean,
// dispatcher functions
fetchBranches: Repository => void,
createBranch: (
createLink: string,
repository: Repository,
branch: BranchRequest,
callback?: (Branch) => void
) => void,
resetForm: Repository => void,
// context objects
t: string => string,
history: History,
location: any
};
class CreateBranch extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
this.props.resetForm(repository);
}
branchCreated = (branch: Branch) => {
const { history, repository } = this.props;
history.push(
`/repo/${repository.namespace}/${
repository.name
}/branch/${encodeURIComponent(branch.name)}/info`
);
};
createBranch = (branch: BranchRequest) => {
this.props.createBranch(
this.props.createBranchesLink,
this.props.repository,
branch,
newBranch => this.branchCreated(newBranch)
);
};
transmittedName = (url: string) => {
const params = queryString.parse(url);
return params.name;
};
render() {
const { t, loading, error, repository, branches, createBranchesLink, location } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading || !branches) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.create.title")} />
<BranchForm
submitForm={branchRequest => this.createBranch(branchRequest)}
loading={loading}
repository={repository}
branches={branches}
transmittedName={this.transmittedName(location.search)}
disabled={!createBranchesLink}
/>
</>
);
}
}
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
},
createBranch: (
createLink: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (newBranch: Branch) => void
) => {
dispatch(createBranch(createLink, repository, branchRequest, callback));
},
resetForm: (repository: Repository) => {
dispatch(createBranchReset(repository));
}
};
};
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const loading =
isFetchBranchesPending(state, repository) ||
isCreateBranchPending(state, repository);
const error =
getFetchBranchesFailure(state, repository) || getCreateBranchFailure(state);
const branches = getBranches(state, repository);
const createBranchesLink = getBranchCreateLink(state, repository);
return {
repository,
loading,
error,
branches,
createBranchesLink
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(CreateBranch))
);

View File

@@ -0,0 +1,368 @@
// @flow
import {
FAILURE_SUFFIX,
PENDING_SUFFIX,
SUCCESS_SUFFIX,
RESET_SUFFIX
} from "../../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import type {
Action,
Branch,
BranchRequest,
Repository
} from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import memoizeOne from "memoize-one";
export const FETCH_BRANCHES = "scm/repos/FETCH_BRANCHES";
export const FETCH_BRANCHES_PENDING = `${FETCH_BRANCHES}_${PENDING_SUFFIX}`;
export const FETCH_BRANCHES_SUCCESS = `${FETCH_BRANCHES}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCHES_FAILURE = `${FETCH_BRANCHES}_${FAILURE_SUFFIX}`;
export const FETCH_BRANCH = "scm/repos/FETCH_BRANCH";
export const FETCH_BRANCH_PENDING = `${FETCH_BRANCH}_${PENDING_SUFFIX}`;
export const FETCH_BRANCH_SUCCESS = `${FETCH_BRANCH}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCH_FAILURE = `${FETCH_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH = "scm/repos/CREATE_BRANCH";
export const CREATE_BRANCH_PENDING = `${CREATE_BRANCH}_${PENDING_SUFFIX}`;
export const CREATE_BRANCH_SUCCESS = `${CREATE_BRANCH}_${SUCCESS_SUFFIX}`;
export const CREATE_BRANCH_FAILURE = `${CREATE_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH_RESET = `${CREATE_BRANCH}_${RESET_SUFFIX}`;
const CONTENT_TYPE_BRANCH_REQUEST =
"application/vnd.scmm-branchRequest+json;v=2";
// Fetching branches
export function fetchBranches(repository: Repository) {
if (!repository._links.branches) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { repository, data: {} },
itemId: createKey(repository)
};
}
return function(dispatch: any) {
dispatch(fetchBranchesPending(repository));
return apiClient
.get(repository._links.branches.href)
.then(response => response.json())
.then(data => {
dispatch(fetchBranchesSuccess(data, repository));
})
.catch(error => {
dispatch(fetchBranchesFailure(repository, error));
});
};
}
export function fetchBranch(repository: Repository, name: string) {
let link = repository._links.branches.href;
if (!link.endsWith("/")) {
link += "/";
}
link += encodeURIComponent(name);
return function(dispatch: any) {
dispatch(fetchBranchPending(repository, name));
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchBranchSuccess(repository, data));
})
.catch(error => {
dispatch(fetchBranchFailure(repository, name, error));
});
};
}
// create branch
export function createBranch(
link: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (branch: Branch) => void
) {
return function(dispatch: any) {
dispatch(createBranchPending(repository));
return apiClient
.post(link, branchRequest, CONTENT_TYPE_BRANCH_REQUEST)
.then(response => response.headers.get("Location"))
.then(location => apiClient.get(location))
.then(response => response.json())
.then(branch => {
dispatch(createBranchSuccess(repository));
if (callback) {
callback(branch);
}
})
.catch(error => dispatch(createBranchFailure(repository, error)));
};
}
// Selectors
function collectBranches(repoState) {
return repoState.list._embedded.branches.map(name => repoState.byName[name]);
}
const memoizedBranchCollector = memoizeOne(collectBranches);
export function getBranches(state: Object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (repoState && repoState.list) {
return memoizedBranchCollector(repoState);
}
}
export function getBranchCreateLink(state: Object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (
repoState &&
repoState.list &&
repoState.list._links &&
repoState.list._links.create
) {
return repoState.list._links.create.href;
}
}
function getRepoState(state: Object, repository: Repository) {
const key = createKey(repository);
const repoState = state.branches[key];
if (repoState && repoState.byName) {
return repoState;
}
}
export const isPermittedToCreateBranches = (
state: Object,
repository: Repository
): boolean => {
const repoState = getRepoState(state, repository);
return !!(
repoState &&
repoState.list &&
repoState.list._links &&
repoState.list._links.create
);
};
export function getBranch(
state: Object,
repository: Repository,
name: string
): ?Branch {
const repoState = getRepoState(state, repository);
if (repoState) {
return repoState.byName[name];
}
}
// Action creators
export function isFetchBranchesPending(
state: Object,
repository: Repository
): boolean {
return isPending(state, FETCH_BRANCHES, createKey(repository));
}
export function getFetchBranchesFailure(state: Object, repository: Repository) {
return getFailure(state, FETCH_BRANCHES, createKey(repository));
}
export function fetchBranchesPending(repository: Repository) {
return {
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: createKey(repository)
};
}
export function fetchBranchesSuccess(data: string, repository: Repository) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { data, repository },
itemId: createKey(repository)
};
}
export function fetchBranchesFailure(repository: Repository, error: Error) {
return {
type: FETCH_BRANCHES_FAILURE,
payload: { error, repository },
itemId: createKey(repository)
};
}
export function isCreateBranchPending(state: Object, repository: Repository) {
return isPending(state, CREATE_BRANCH, createKey(repository));
}
export function getCreateBranchFailure(state: Object) {
return getFailure(state, CREATE_BRANCH);
}
export function createBranchPending(repository: Repository): Action {
return {
type: CREATE_BRANCH_PENDING,
payload: { repository },
itemId: createKey(repository)
};
}
export function createBranchSuccess(repository: Repository): Action {
return {
type: CREATE_BRANCH_SUCCESS,
payload: { repository },
itemId: createKey(repository)
};
}
export function createBranchFailure(
repository: Repository,
error: Error
): Action {
return {
type: CREATE_BRANCH_FAILURE,
payload: { repository, error },
itemId: createKey(repository)
};
}
export function createBranchReset(repository: Repository): Action {
return {
type: CREATE_BRANCH_RESET,
payload: { repository },
itemId: createKey(repository)
};
}
export function isFetchBranchPending(
state: Object,
repository: Repository,
name: string
) {
return isPending(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function getFetchBranchFailure(
state: Object,
repository: Repository,
name: string
) {
return getFailure(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function fetchBranchPending(
repository: Repository,
name: string
): Action {
return {
type: FETCH_BRANCH_PENDING,
payload: { repository, name },
itemId: createKey(repository) + "/" + name
};
}
export function fetchBranchSuccess(
repository: Repository,
branch: Branch
): Action {
return {
type: FETCH_BRANCH_SUCCESS,
payload: { repository, branch },
itemId: createKey(repository) + "/" + branch.name
};
}
export function fetchBranchFailure(
repository: Repository,
name: string,
error: Error
): Action {
return {
type: FETCH_BRANCH_FAILURE,
payload: { error, repository, name },
itemId: createKey(repository) + "/" + name
};
}
// Reducers
const reduceByBranchesSuccess = (state, payload) => {
const repository = payload.repository;
const response = payload.data;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
repoState.byName = byName;
if (response._embedded) {
const branches = response._embedded.branches;
response._embedded.branches = branches.map(b => b.name);
for (let branch of branches) {
byName[branch.name] = branch;
}
return {
[key]: {
list: response,
byName
}
};
}
return {
[key]: []
};
};
const reduceByBranchSuccess = (state, payload) => {
const repository = payload.repository;
const branch = payload.branch;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
byName[branch.name] = branch;
repoState.byName = byName;
return {
...state,
[key]: repoState
};
};
export default function reducer(
state: {} = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_BRANCHES_SUCCESS:
return reduceByBranchesSuccess(state, payload);
case FETCH_BRANCH_SUCCESS:
return reduceByBranchSuccess(state, payload);
default:
return state;
}
}
function createKey(repository: Repository): string {
const { namespace, name } = repository;
return `${namespace}/${name}`;
}

View File

@@ -0,0 +1,471 @@
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_BRANCHES,
FETCH_BRANCHES_FAILURE,
FETCH_BRANCHES_PENDING,
FETCH_BRANCHES_SUCCESS,
FETCH_BRANCH_PENDING,
FETCH_BRANCH_SUCCESS,
FETCH_BRANCH_FAILURE,
CREATE_BRANCH,
CREATE_BRANCH_FAILURE,
CREATE_BRANCH_PENDING,
CREATE_BRANCH_SUCCESS,
fetchBranches,
fetchBranch,
fetchBranchSuccess,
getBranch,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending,
createBranch,
isCreateBranchPending,
getCreateBranchFailure,
isPermittedToCreateBranches
} from "./branches";
const namespace = "foo";
const name = "bar";
const key = namespace + "/" + name;
const repository = {
namespace: "foo",
name: "bar",
_links: {
branches: {
href: "http://scm/api/rest/v2/repositories/foo/bar/branches"
}
}
};
const branch1 = { name: "branch1", revision: "revision1" };
const branch2 = { name: "branch2", revision: "revision2" };
const branch3 = { name: "branch3", revision: "revision3" };
const branchRequest = { name: "newBranch", source: "master" };
const newBranch = { name: "newBranch", revision: "rev3" };
describe("branches", () => {
describe("fetch branches", () => {
const URL = "http://scm/api/rest/v2/repositories/foo/bar/branches";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch branches", () => {
const collection = {};
fetchMock.getOnce(URL, "{}");
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_SUCCESS,
payload: { data: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching branches on HTTP 500", () => {
const collection = {};
fetchMock.getOnce(URL, 500);
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_FAILURE,
payload: { error: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_BRANCHES_FAILURE);
});
});
it("should successfully fetch single branch", () => {
fetchMock.getOnce(URL + "/branch1", branch1);
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch1")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single branch on HTTP 500", () => {
fetchMock.getOnce(URL + "/branch2", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch2")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should create a branch successfully", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
return store
.dispatch(createBranch(URL, repository, branchRequest))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_SUCCESS);
});
});
it("should call the callback with the branch from the location header", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
let receivedBranch = null;
const callback = branch => {
receivedBranch = branch;
};
return store
.dispatch(createBranch(URL, repository, branchRequest, callback))
.then(() => {
expect(receivedBranch).toEqual(newBranch);
});
});
it("should fail creating a branch on HTTP 500", () => {
fetchMock.postOnce(URL, {
status: 500
});
const store = mockStore({});
return store
.dispatch(createBranch(URL, repository, branchRequest))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_FAILURE);
});
});
});
describe("branches reducer", () => {
const branches = {
_embedded: {
branches: [branch1, branch2]
},
_links: {
self: {
href: "/self"
},
create: {
href: "/create"
}
}
};
const action = {
type: FETCH_BRANCHES_SUCCESS,
payload: {
repository,
data: branches
}
};
it("should store the branches", () => {
const newState = reducer({}, action);
const repoState = newState["foo/bar"];
expect(repoState.list._links.create.href).toEqual("/create");
expect(repoState.list._embedded.branches).toEqual(["branch1", "branch2"]);
expect(repoState.byName.branch1).toEqual(branch1);
expect(repoState.byName.branch2).toEqual(branch2);
});
it("should store a single branch", () => {
const newState = reducer({}, fetchBranchSuccess(repository, branch1));
const repoState = newState["foo/bar"];
expect(repoState.list).toBeUndefined();
expect(repoState.byName.branch1).toEqual(branch1);
});
it("should add a single branch", () => {
const state = {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1"]
}
},
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch2));
const repoState = newState["foo/bar"];
const byName = repoState.byName;
expect(repoState.list._embedded.branches).toEqual(["branch1"]);
expect(byName.branch1).toEqual(branch1);
expect(byName.branch2).toEqual(branch2);
});
it("should not overwrite non related repositories", () => {
const state = {
"scm/core": {
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["scm/core"].byName;
expect(byName.branch1).toEqual(branch1);
});
it("should overwrite existing branch", () => {
const state = {
"foo/bar": {
byName: {
branch1: {
name: "branch1",
revision: "xyz"
}
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["foo/bar"].byName;
expect(byName.branch1.revision).toEqual("revision1");
});
it("should not overwrite existing branches", () => {
const state = {
"foo/bar": {
byName: {
branch1,
branch2,
branch3
}
}
};
const newState = reducer(state, action);
expect(newState["foo/bar"].byName.branch1).toEqual(branch1);
expect(newState["foo/bar"].byName.branch2).toEqual(branch2);
expect(newState["foo/bar"].byName.branch3).toEqual(branch3);
});
});
describe("branch selectors", () => {
const error = new Error("Something went wrong");
const state = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1", "branch2"]
}
},
byName: {
branch1: branch1,
branch2: branch2
}
}
}
};
it("should return true, when fetching branches is pending", () => {
const state = {
pending: {
[FETCH_BRANCHES + "/foo/bar"]: true
}
};
expect(isFetchBranchesPending(state, repository)).toBeTruthy();
});
it("should return branches", () => {
const branches = getBranches(state, repository);
expect(branches.length).toEqual(2);
expect(branches).toContain(branch1);
expect(branches).toContain(branch2);
});
it("should return always the same reference for branches", () => {
const one = getBranches(state, repository);
const two = getBranches(state, repository);
expect(one).toBe(two);
});
it("should not return cached reference, if branches have changed", () => {
const one = getBranches(state, repository);
const newState = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch2", "branch3"]
}
},
byName: {
branch2,
branch3
}
}
}
};
const two = getBranches(newState, repository);
expect(one).not.toBe(two);
expect(two).not.toContain(branch1);
expect(two).toContain(branch2);
expect(two).toContain(branch3);
});
it("should return undefined, if no branches for the repository available", () => {
const branches = getBranches({ branches: {} }, repository);
expect(branches).toBeUndefined();
});
it("should return single branch by name", () => {
const branch = getBranch(state, repository, "branch1");
expect(branch).toEqual(branch1);
});
it("should return same reference for single branch by name", () => {
const one = getBranch(state, repository, "branch1");
const two = getBranch(state, repository, "branch1");
expect(one).toBe(two);
});
it("should return undefined if branch does not exist", () => {
const branch = getBranch(state, repository, "branch42");
expect(branch).toBeUndefined();
});
it("should return true if the branches list contains the create link", () => {
const stateWithLink = {
branches: {
"foo/bar": {
...state.branches["foo/bar"],
list: {
...state.branches["foo/bar"].list,
_links: {
create: {
href: "http://create-it"
}
}
}
}
}
};
const permitted = isPermittedToCreateBranches(stateWithLink, repository);
expect(permitted).toBe(true);
});
it("should return false if the create link is missing", () => {
const permitted = isPermittedToCreateBranches(state, repository);
expect(permitted).toBe(false);
});
it("should return error if fetching branches failed", () => {
const state = {
failure: {
[FETCH_BRANCHES + "/foo/bar"]: error
}
};
expect(getFetchBranchesFailure(state, repository)).toEqual(error);
});
it("should return false if fetching branches did not fail", () => {
expect(getFetchBranchesFailure({}, repository)).toBeUndefined();
});
it("should return true if create branch is pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: true
}
};
expect(isCreateBranchPending(state, repository)).toBe(true);
});
it("should return false if create branch is not pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: false
}
};
expect(isCreateBranchPending(state, repository)).toBe(false);
});
it("should return error when create branch did fail", () => {
const state = {
failure: {
[CREATE_BRANCH]: error
}
};
expect(getCreateBranchFailure(state)).toEqual(error);
});
it("should return undefined when create branch did not fail", () => {
expect(getCreateBranchFailure({})).toBe(undefined);
});
});
});

View File

@@ -0,0 +1,32 @@
// @flow
// master, default should always be the first one,
// followed by develop the rest should be ordered by its name
import type {Branch} from "@scm-manager/ui-types";
export function orderBranches(branches: Branch[]) {
branches.sort((a, b) => {
if (a.defaultBranch && !b.defaultBranch) {
return -20;
} else if (!a.defaultBranch && b.defaultBranch) {
return 20;
} else if (a.name === "master" && b.name !== "master") {
return -10;
} else if (a.name !== "master" && b.name === "master") {
return 10;
} else if (a.name === "default" && b.name !== "default") {
return -10;
} else if (a.name !== "default" && b.name === "default") {
return 10;
} else if (a.name === "develop" && b.name !== "develop") {
return -5;
} else if (a.name !== "develop" && b.name === "develop") {
return 5;
} else if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
});
}

View File

@@ -0,0 +1,51 @@
import { orderBranches } from "./orderBranches";
const branch1 = { name: "branch1", revision: "revision1" };
const branch2 = { name: "branch2", revision: "revision2" };
const branch3 = { name: "branch3", revision: "revision3", defaultBranch: true };
const defaultBranch = {
name: "default",
revision: "revision4",
defaultBranch: false
};
const developBranch = {
name: "develop",
revision: "revision5",
defaultBranch: false
};
const masterBranch = {
name: "master",
revision: "revision6",
defaultBranch: false
};
describe("order branches", () => {
it("should return branches", () => {
let branches = [branch1, branch2];
orderBranches(branches);
expect(branches).toEqual([branch1, branch2]);
});
it("should return defaultBranch first", () => {
let branches = [branch1, branch2, branch3];
orderBranches(branches);
expect(branches).toEqual([branch3, branch1, branch2]);
});
it("should order special branches as follows: master > default > develop", () => {
let branches = [defaultBranch, developBranch, masterBranch];
orderBranches(branches);
expect(branches).toEqual([masterBranch, defaultBranch, developBranch]);
});
it("should order special branches but starting with defaultBranch", () => {
let branches = [masterBranch, developBranch, defaultBranch, branch3];
orderBranches(branches);
expect(branches).toEqual([
branch3,
masterBranch,
defaultBranch,
developBranch
]);
});
});

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
editUrl: string,
t: string => string
};
class EditRepoNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.repository._links.update;
};
render() {
const { editUrl, t } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink to={editUrl} label={t("repositoryRoot.menu.generalNavLink")} />;
}
}
export default translate("repos")(EditRepoNavLink);

View File

@@ -0,0 +1,38 @@
import React from "react";
import { shallow, mount } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import EditRepoNavLink from "./EditRepoNavLink";
describe("GeneralNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
update: {
href: "/repositories"
}
}
};
const navLink = mount(
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
});
});

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
permissionUrl: string,
t: string => string,
repository: Repository
};
class PermissionsNavLink extends React.Component<Props> {
hasPermissionsLink = () => {
return this.props.repository._links.permissions;
};
render() {
if (!this.hasPermissionsLink()) {
return null;
}
const { permissionUrl, t } = this.props;
return (
<NavLink to={permissionUrl} label={t("repositoryRoot.menu.permissionsNavLink")} />
);
}
}
export default translate("repos")(PermissionsNavLink);

View File

@@ -0,0 +1,38 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import PermissionsNavLink from "./PermissionsNavLink";
describe("PermissionsNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
permissions: {
href: "/permissions"
}
}
};
const navLink = mount(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
});
});

View File

@@ -0,0 +1,55 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { MailLink, DateFromNow } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
// context props
t: string => string
};
class RepositoryDetailTable extends React.Component<Props> {
render() {
const { repository, t } = this.props;
return (
<table className="table">
<tbody>
<tr>
<th>{t("repository.name")}</th>
<td>{repository.name}</td>
</tr>
<tr>
<th>{t("repository.type")}</th>
<td>{repository.type}</td>
</tr>
<tr>
<th>{t("repository.contact")}</th>
<td>
<MailLink address={repository.contact} />
</td>
</tr>
<tr>
<th>{t("repository.description")}</th>
<td>{repository.description}</td>
</tr>
<tr>
<th>{t("repository.creationDate")}</th>
<td>
<DateFromNow date={repository.creationDate} />
</td>
</tr>
<tr>
<th>{t("repository.lastModified")}</th>
<td>
<DateFromNow date={repository.lastModified} />
</td>
</tr>
</tbody>
</table>
);
}
}
export default translate("repos")(RepositoryDetailTable);

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import RepositoryDetailTable from "./RepositoryDetailTable";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
repository: Repository
};
class RepositoryDetails extends React.Component<Props> {
render() {
const { repository } = this.props;
return (
<div>
<RepositoryDetailTable repository={repository}/>
<hr/>
<div className="content">
<ExtensionPoint
name="repos.repository-details.information"
renderAll={true}
props={{ repository }}
/>
</div>
</div>
);
}
}
export default RepositoryDetails;

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
type Props = {
repository: Repository,
to: string,
label: string,
linkName: string,
activeWhenMatch?: (route: any) => boolean,
activeOnlyWhenExact: boolean
};
/**
* Component renders only if the repository contains the link with the given name.
*/
class RepositoryNavLink extends React.Component<Props> {
render() {
const { repository, linkName } = this.props;
if (!repository._links[linkName]) {
return null;
}
return <NavLink {...this.props} />;
}
}
export default RepositoryNavLink;

View File

@@ -0,0 +1,57 @@
// @flow
import React from "react";
import { shallow, mount } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import RepositoryNavLink from "./RepositoryNavLink";
describe("RepositoryNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the sources link is missing", () => {
const repository = {
namespace: "Namespace",
name: "Repo",
type: "GIT",
_links: {}
};
const navLink = shallow(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
activeOnlyWhenExact={true}
/>,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
namespace: "Namespace",
name: "Repo",
type: "GIT",
_links: {
sources: {
href: "/sources"
}
}
};
const navLink = mount(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
activeOnlyWhenExact={true}
/>,
options.get()
);
expect(navLink.text()).toBe("Sources");
});
});

View File

@@ -0,0 +1,129 @@
//@flow
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import { Interpolate, translate } from "react-i18next";
import injectSheet from "react-jss";
import {
DateFromNow,
ChangesetId,
ChangesetTag,
ChangesetAuthor,
ChangesetDiff,
AvatarWrapper,
AvatarImage,
changesets
} from "@scm-manager/ui-components";
import classNames from "classnames";
import type { Tag } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
const styles = {
spacing: {
marginRight: "1em"
},
tags: {
"& .tag": {
marginLeft: ".25rem"
}
}
};
type Props = {
changeset: Changeset,
repository: Repository,
t: string => string,
classes: any
};
class ChangesetDetails extends React.Component<Props> {
render() {
const { changeset, repository, classes } = this.props;
const description = changesets.parseDescription(changeset.description);
const id = (
<ChangesetId repository={repository} changeset={changeset} link={false} />
);
const date = <DateFromNow date={changeset.date} />;
return (
<div>
<div className="content">
<h4>
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
{description.title}
</ExtensionPoint>
</h4>
<article className="media">
<AvatarWrapper>
<p className={classNames("image", "is-64x64", classes.spacing)}>
<AvatarImage person={changeset.author} />
</p>
</AvatarWrapper>
<div className="media-content">
<p>
<ChangesetAuthor changeset={changeset} />
</p>
<p>
<Interpolate
i18nKey="changeset.summary"
id={id}
time={date}
/>
</p>
</div>
<div className="media-right">{this.renderTags()}</div>
</article>
<p>
{description.message.split("\n").map((item, key) => {
return (
<span key={key}>
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: item }}
renderAll={false}
>
{item}
</ExtensionPoint>
<br />
</span>
);
})}
</p>
</div>
<div>
<ChangesetDiff changeset={changeset} />
</div>
</div>
);
}
getTags = () => {
const { changeset } = this.props;
return changeset._embedded.tags || [];
};
renderTags = () => {
const { classes } = this.props;
const tags = this.getTags();
if (tags.length > 0) {
return (
<div className={classNames("level-item", classes.tags)}>
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}
</div>
);
}
return null;
};
}
export default injectSheet(styles)(translate("repos")(ChangesetDetails));

View File

@@ -0,0 +1,238 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import {
Subtitle,
InputField,
Select,
SubmitButton,
Textarea
} from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository, RepositoryType } from "@scm-manager/ui-types";
import * as validator from "./repositoryValidation";
type Props = {
submitForm: Repository => void,
repository?: Repository,
repositoryTypes: RepositoryType[],
namespaceStrategy: string,
loading?: boolean,
t: string => string
};
type State = {
repository: Repository,
namespaceValidationError: boolean,
nameValidationError: boolean,
contactValidationError: boolean
};
const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy";
class RepositoryForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
repository: {
name: "",
namespace: "",
type: "",
contact: "",
description: "",
_links: {}
},
namespaceValidationError: false,
nameValidationError: false,
contactValidationError: false
};
}
componentDidMount() {
const { repository } = this.props;
if (repository) {
this.setState({ repository: { ...repository } });
}
}
isFalsy(value) {
if (!value) {
return true;
}
return false;
}
isValid = () => {
const { namespaceStrategy } = this.props;
const { repository } = this.state;
return !(
this.state.namespaceValidationError ||
this.state.nameValidationError ||
this.state.contactValidationError ||
this.isFalsy(repository.name) ||
(namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY &&
this.isFalsy(repository.namespace))
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.repository);
}
};
isCreateMode = () => {
return !this.props.repository;
};
isModifiable = () => {
return !!this.props.repository && !!this.props.repository._links.update;
};
render() {
const { loading, t } = this.props;
const repository = this.state.repository;
const disabled = !this.isModifiable() && !this.isCreateMode();
const submitButton = disabled ? null : (
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("repositoryForm.submit")}
/>
);
let subtitle = null;
if (this.props.repository) {
// edit existing repo
subtitle = <Subtitle subtitle={t("repositoryForm.subtitle")} />;
}
return (
<>
{subtitle}
<form onSubmit={this.submit}>
{this.renderCreateOnlyFields()}
<InputField
label={t("repository.contact")}
onChange={this.handleContactChange}
value={repository ? repository.contact : ""}
validationError={this.state.contactValidationError}
errorMessage={t("validation.contact-invalid")}
helpText={t("help.contactHelpText")}
disabled={disabled}
/>
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
helpText={t("help.descriptionHelpText")}
disabled={disabled}
/>
{submitButton}
</form>
</>
);
}
createSelectOptions(repositoryTypes: RepositoryType[]) {
return repositoryTypes.map(repositoryType => {
return {
label: repositoryType.displayName,
value: repositoryType.name
};
});
}
renderNamespaceField = () => {
const { namespaceStrategy, t } = this.props;
const repository = this.state.repository;
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: repository ? repository.namespace : "",
onChange: this.handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: this.state.namespaceValidationError
};
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return (
<ExtensionPoint
name="repos.create.namespace"
props={props}
renderAll={false}
/>
);
};
renderCreateOnlyFields() {
if (!this.isCreateMode()) {
return null;
}
const { repositoryTypes, t } = this.props;
const repository = this.state.repository;
return (
<>
{this.renderNamespaceField()}
<InputField
label={t("repository.name")}
onChange={this.handleNameChange}
value={repository ? repository.name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.name-invalid")}
helpText={t("help.nameHelpText")}
/>
<Select
label={t("repository.type")}
onChange={this.handleTypeChange}
value={repository ? repository.type : ""}
options={this.createSelectOptions(repositoryTypes)}
helpText={t("help.typeHelpText")}
/>
</>
);
}
handleNamespaceChange = (namespace: string) => {
this.setState({
namespaceValidationError: !validator.isNameValid(namespace),
repository: { ...this.state.repository, namespace }
});
};
handleNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
repository: { ...this.state.repository, name }
});
};
handleTypeChange = (type: string) => {
this.setState({
repository: { ...this.state.repository, type }
});
};
handleContactChange = (contact: string) => {
this.setState({
contactValidationError: !validator.isContactValid(contact),
repository: { ...this.state.repository, contact }
});
};
handleDescriptionChange = (description: string) => {
this.setState({
repository: { ...this.state.repository, description }
});
};
}
export default translate("repos")(RepositoryForm);

View File

@@ -0,0 +1,2 @@
import RepositoryForm from "./RepositoryForm";
export default RepositoryForm;

View File

@@ -0,0 +1,12 @@
// @flow
import { validation } from "@scm-manager/ui-components";
const nameRegex = /(?!^\.\.$)(?!^\.$)(?!.*[\\\[\]])^[A-Za-z0-9\.][A-Za-z0-9\.\-_]*$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
export function isContactValid(mail: string) {
return "" === mail || validation.isMailValid(mail);
}

View File

@@ -0,0 +1,106 @@
import * as validator from "./repositoryValidation";
describe("repository name validation", () => {
// we don't need rich tests, because they are in validation.test.js
it("should validate the name", () => {
expect(validator.isNameValid("scm-manager")).toBe(true);
});
it("should fail for old nested repository names", () => {
// in v2 this is not allowed
expect(validator.isNameValid("scm/manager")).toBe(false);
expect(validator.isNameValid("scm/ma/nager")).toBe(false);
});
it("should allow same names as the backend", () => {
const validPaths = [
"scm",
"s",
"sc",
".hiddenrepo",
"b.",
"...",
"..c",
"d..",
"a..c"
];
validPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(true)
);
});
it("should deny same names as the backend", () => {
const invalidPaths = [
".",
"/",
"//",
"..",
"/.",
"/..",
"./",
"../",
"/../",
"/./",
"/...",
"/abc",
".../",
"/sdf/",
"asdf/",
"./b",
"scm/plugins/.",
"scm/../plugins",
"scm/main/",
"/scm/main/",
"scm/./main",
"scm//main",
"scm\\main",
"scm/main-$HOME",
"scm/main-${HOME}-home",
"scm/main-%HOME-home",
"scm/main-%HOME%-home",
"abc$abc",
"abc%abc",
"abc<abc",
"abc>abc",
"abc#abc",
"abc+abc",
"abc{abc",
"abc}abc",
"abc(abc",
"abc)abc",
"abc[abc",
"abc]abc",
"abc|abc",
"scm/main",
"scm/plugins/git-plugin",
".scm/plugins",
"a/b..",
"a/..b",
"scm/main",
"scm/plugins/git-plugin",
"scm/plugins/git-plugin"
];
invalidPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(false)
);
});
});
describe("repository contact validation", () => {
it("should allow empty contact", () => {
expect(validator.isContactValid("")).toBe(true);
});
// we don't need rich tests, because they are in validation.test.js
it("should allow real mail addresses", () => {
expect(validator.isContactValid("trici.mcmillian@hitchhiker.com")).toBe(
true
);
});
it("should fail on invalid mail addresses", () => {
expect(validator.isContactValid("tricia")).toBe(false);
});
});

View File

@@ -0,0 +1,24 @@
//@flow
import React from "react";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository } from "@scm-manager/ui-types";
import { Image } from "@scm-manager/ui-components";
type Props = {
repository: Repository
};
class RepositoryAvatar extends React.Component<Props> {
render() {
const { repository } = this.props;
return (
<p className="image is-64x64">
<ExtensionPoint name="repos.repository-avatar" props={{ repository }}>
<Image src="/images/blib.jpg" alt="Logo" />
</ExtensionPoint>
</p>
);
}
}
export default RepositoryAvatar;

View File

@@ -0,0 +1,102 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { CardColumn, DateFromNow } from "@scm-manager/ui-components";
import RepositoryEntryLink from "./RepositoryEntryLink";
import RepositoryAvatar from "./RepositoryAvatar";
type Props = {
repository: Repository
};
class RepositoryEntry extends React.Component<Props> {
createLink = (repository: Repository) => {
return `/repo/${repository.namespace}/${repository.name}`;
};
renderBranchesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["branches"]) {
return (
<RepositoryEntryLink
iconClass="fas fa-code-branch fa-lg"
to={repositoryLink + "/branches"}
/>
);
}
return null;
};
renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["changesets"]) {
return (
<RepositoryEntryLink
iconClass="fas fa-exchange-alt fa-lg"
to={repositoryLink + "/changesets"}
/>
);
}
return null;
};
renderSourcesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["sources"]) {
return (
<RepositoryEntryLink
iconClass="fa-code fa-lg"
to={repositoryLink + "/sources"}
/>
);
}
return null;
};
renderModifyLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["update"]) {
return (
<RepositoryEntryLink
iconClass="fa-cog fa-lg"
to={repositoryLink + "/settings/general"}
/>
);
}
return null;
};
createFooterLeft = (repository: Repository, repositoryLink: string) => {
return (
<>
{this.renderBranchesLink(repository, repositoryLink)}
{this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)}
{this.renderModifyLink(repository, repositoryLink)}
</>
);
};
createFooterRight = (repository: Repository) => {
return (
<small className="level-item">
<DateFromNow date={repository.creationDate} />
</small>
);
};
render() {
const { repository } = this.props;
const repositoryLink = this.createLink(repository);
const footerLeft = this.createFooterLeft(repository, repositoryLink);
const footerRight = this.createFooterRight(repository);
return (
<CardColumn
avatar={<RepositoryAvatar repository={repository} />}
title={repository.name}
description={repository.description}
link={repositoryLink}
footerLeft={footerLeft}
footerRight={footerRight}
/>
);
}
}
export default RepositoryEntry;

View File

@@ -0,0 +1,35 @@
//@flow
import React from "react";
import { Link } from "react-router-dom";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
link: {
pointerEvents: "all",
marginRight: "1.25rem !important"
}
};
type Props = {
to: string,
iconClass: string,
// context props
classes: any
};
class RepositoryEntryLink extends React.Component<Props> {
render() {
const { to, iconClass, classes } = this.props;
return (
<Link className={classNames("level-item", classes.link)} to={to}>
<span className="icon is-small">
<i className={classNames("fa", iconClass)} />
</span>
</Link>
);
}
}
export default injectSheet(styles)(RepositoryEntryLink);

View File

@@ -0,0 +1,21 @@
//@flow
import React from "react";
import { CardColumnGroup } from "@scm-manager/ui-components";
import type { RepositoryGroup } from "@scm-manager/ui-types";
import RepositoryEntry from "./RepositoryEntry";
type Props = {
group: RepositoryGroup
};
class RepositoryGroupEntry extends React.Component<Props> {
render() {
const { group } = this.props;
const entries = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
});
return <CardColumnGroup name={group.name} elements={entries} />;
}
}
export default RepositoryGroupEntry;

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import groupByNamespace from "./groupByNamespace";
import RepositoryGroupEntry from "./RepositoryGroupEntry";
type Props = {
repositories: Repository[]
};
class RepositoryList extends React.Component<Props> {
render() {
const { repositories } = this.props;
const groups = groupByNamespace(repositories);
return (
<div className="content">
{groups.map(group => {
return <RepositoryGroupEntry group={group} key={group.name} />;
})}
</div>
);
}
}
export default RepositoryList;

View File

@@ -0,0 +1,39 @@
// @flow
import type { Repository, RepositoryGroup } from "@scm-manager/ui-types";
export default function groupByNamespace(
repositories: Repository[]
): RepositoryGroup[] {
let groups = {};
for (let repository of repositories) {
const groupName = repository.namespace;
let group = groups[groupName];
if (!group) {
group = {
name: groupName,
repositories: []
};
groups[groupName] = group;
}
group.repositories.push(repository);
}
let groupArray = [];
for (let groupName in groups) {
const group = groups[groupName];
group.repositories.sort(sortByName);
groupArray.push(groups[groupName]);
}
groupArray.sort(sortByName);
return groupArray;
}
function sortByName(a, b) {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
}

View File

@@ -0,0 +1,74 @@
// @flow
import groupByNamespace from "./groupByNamespace";
const base = {
type: "git",
_links: {}
};
const slartiBlueprintsFjords = {
...base,
namespace: "slarti",
name: "fjords-blueprints"
};
const slartiFjords = {
...base,
namespace: "slarti",
name: "fjords"
};
const hitchhikerRestand = {
...base,
namespace: "hitchhiker",
name: "restand"
};
const hitchhikerPuzzle42 = {
...base,
namespace: "hitchhiker",
name: "puzzle42"
};
const hitchhikerHeartOfGold = {
...base,
namespace: "hitchhiker",
name: "heartOfGold"
};
const zaphodMarvinFirmware = {
...base,
namespace: "zaphod",
name: "marvin-firmware"
};
it("should group the repositories by their namespace", () => {
const repositories = [
zaphodMarvinFirmware,
slartiBlueprintsFjords,
hitchhikerRestand,
slartiFjords,
hitchhikerHeartOfGold,
hitchhikerPuzzle42
];
const expected = [
{
name: "hitchhiker",
repositories: [
hitchhikerHeartOfGold,
hitchhikerPuzzle42,
hitchhikerRestand
]
},
{
name: "slarti",
repositories: [slartiFjords, slartiBlueprintsFjords]
},
{
name: "zaphod",
repositories: [zaphodMarvinFirmware]
}
];
expect(groupByNamespace(repositories)).toEqual(expected);
});

View File

@@ -0,0 +1,2 @@
import RepositoryList from "./RepositoryList";
export default RepositoryList;

View File

@@ -0,0 +1,75 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import {
fetchChangesetIfNeeded,
getChangeset,
getFetchChangesetFailure,
isFetchChangesetPending
} from "../modules/changesets";
import ChangesetDetails from "../components/changesets/ChangesetDetails";
import { translate } from "react-i18next";
import { ErrorPage, Loading } from "@scm-manager/ui-components";
type Props = {
id: string,
changeset: Changeset,
repository: Repository,
loading: boolean,
error: Error,
fetchChangesetIfNeeded: (repository: Repository, id: string) => void,
match: any,
t: string => string
};
class ChangesetView extends React.Component<Props> {
componentDidMount() {
const { fetchChangesetIfNeeded, repository } = this.props;
const id = this.props.match.params.id;
fetchChangesetIfNeeded(repository, id);
}
render() {
const { changeset, loading, error, t, repository } = this.props;
if (error) {
return (
<ErrorPage
title={t("changesets.errorTitle")}
subtitle={t("changesets.errorSubtitle")}
error={error}
/>
);
}
if (!changeset || loading) return <Loading />;
return <ChangesetDetails changeset={changeset} repository={repository} />;
}
}
const mapStateToProps = (state, ownProps: Props) => {
const repository = ownProps.repository;
const id = ownProps.match.params.id;
const changeset = getChangeset(state, repository, id);
const loading = isFetchChangesetPending(state, repository, id);
const error = getFetchChangesetFailure(state, repository, id);
return { changeset, error, loading };
};
const mapDispatchToProps = dispatch => {
return {
fetchChangesetIfNeeded: (repository: Repository, id: string) => {
dispatch(fetchChangesetIfNeeded(repository, id));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("changesets")(ChangesetView))
);

View File

@@ -0,0 +1,133 @@
// @flow
import React from "react";
import { withRouter } from "react-router-dom";
import type {
Branch,
Changeset,
PagedCollection,
Repository
} from "@scm-manager/ui-types";
import {
fetchChangesets,
getChangesets,
getFetchChangesetsFailure,
isFetchChangesetsPending,
selectListAsCollection
} from "../modules/changesets";
import { connect } from "react-redux";
import {
ErrorNotification,
getPageFromMatch,
LinkPaginator,
ChangesetList,
Loading,
Notification
} from "@scm-manager/ui-components";
import { compose } from "redux";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
branch: Branch,
page: number,
// State props
changesets: Changeset[],
list: PagedCollection,
loading: boolean,
error: Error,
// Dispatch props
fetchChangesets: (Repository, Branch, number) => void,
// context props
match: any,
t: string => string
};
class Changesets extends React.Component<Props> {
componentDidMount() {
const { fetchChangesets, repository, branch, page } = this.props;
fetchChangesets(repository, branch, page);
}
render() {
const { changesets, loading, error, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!changesets || changesets.length === 0) {
return (
<div className="panel-block">
<Notification type="info">
{t("changesets.noChangesets")}
</Notification>
</div>
);
}
return (
<>
{this.renderList()}
{this.renderPaginator()}
</>
);
}
renderList = () => {
const { repository, changesets } = this.props;
return (
<div className="panel-block">
<ChangesetList repository={repository} changesets={changesets} />
</div>
);
};
renderPaginator = () => {
const { page, list } = this.props;
if (list) {
return (
<div className="panel-footer">
<LinkPaginator page={page} collection={list} />
</div>
);
}
return null;
};
}
const mapDispatchToProps = dispatch => {
return {
fetchChangesets: (repo: Repository, branch: Branch, page: number) => {
dispatch(fetchChangesets(repo, branch, page));
}
};
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, branch, match } = ownProps;
const changesets = getChangesets(state, repository, branch);
const loading = isFetchChangesetsPending(state, repository, branch);
const error = getFetchChangesetsFailure(state, repository, branch);
const list = selectListAsCollection(state, repository, branch);
const page = getPageFromMatch(match);
return { changesets, list, page, loading, error };
};
export default compose(
translate("repos"),
withRouter,
connect(
mapStateToProps,
mapDispatchToProps
)
)(Changesets);

View File

@@ -0,0 +1,170 @@
// @flow
import React from "react";
import type { Branch, Repository } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import { Route, withRouter } from "react-router-dom";
import Changesets from "./Changesets";
import { connect } from "react-redux";
import {
BranchSelector,
ErrorNotification,
Loading
} from "@scm-manager/ui-components";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending
} from "../branches/modules/branches";
import { compose } from "redux";
type Props = {
repository: Repository,
baseUrl: string,
selected: string,
baseUrlWithBranch: string,
baseUrlWithoutBranch: string,
// State props
branches: Branch[],
loading: boolean,
error: Error,
// Dispatch props
fetchBranches: Repository => void,
// Context props
history: any, // TODO flow type
match: any,
t: string => string
};
class ChangesetsRoot extends React.Component<Props> {
componentDidMount() {
this.props.fetchBranches(this.props.repository);
this.redirectToDefaultBranch();
}
redirectToDefaultBranch = () => {
if (this.shouldRedirectToDefaultBranch()) {
const defaultBranches = this.props.branches.filter(
b => b.defaultBranch === true
);
if (defaultBranches.length > 0) {
this.branchSelected(defaultBranches[0]);
}
}
};
shouldRedirectToDefaultBranch = () => {
return (
this.props.branches &&
this.props.branches.length > 0 &&
this.props.selected !==
this.props.branches.filter(b => b.defaultBranch === true)[0]
);
};
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
branchSelected = (branch?: Branch) => {
let url;
if (branch) {
url = `${this.props.baseUrlWithBranch}/${encodeURIComponent(
branch.name
)}/changesets/`;
} else {
url = `${this.props.baseUrlWithoutBranch}/`;
}
this.props.history.push(url);
};
findSelectedBranch = () => {
const { selected, branches } = this.props;
return branches.find((branch: Branch) => branch.name === selected);
};
render() {
const { repository, error, loading, match, branches } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!repository) {
return null;
}
const url = this.stripEndingSlash(match.url);
const branch = branches ? this.findSelectedBranch() : null;
const changesets = <Changesets repository={repository} branch={branch} />;
return (
<div className="panel">
{this.renderBranchSelector()}
<Route path={`${url}/:page?`} component={() => changesets} />
</div>
);
}
renderBranchSelector = () => {
const { repository, branches, selected, t } = this.props;
if (repository._links.branches) {
return (
<div className="panel-heading">
<BranchSelector
label={t("changesets.branchSelectorLabel")}
branches={branches}
selectedBranch={selected}
selected={(b: Branch) => {
this.branchSelected(b);
}}
/>
</div>
);
}
return null;
};
}
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repo: Repository) => {
dispatch(fetchBranches(repo));
}
};
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, match } = ownProps;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const selected = decodeURIComponent(match.params.branch);
return {
loading,
error,
branches,
selected
};
};
export default compose(
withRouter,
translate("repos"),
connect(
mapStateToProps,
mapDispatchToProps
)
)(ChangesetsRoot);

View File

@@ -0,0 +1,141 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Page } from "@scm-manager/ui-components";
import RepositoryForm from "../components/form";
import type { Repository, RepositoryType, NamespaceStrategies } from "@scm-manager/ui-types";
import {
fetchRepositoryTypesIfNeeded,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending
} from "../modules/repositoryTypes";
import {
createRepo,
createRepoReset,
getCreateRepoFailure,
isCreateRepoPending
} from "../modules/repos";
import type { History } from "history";
import { getRepositoriesLink } from "../../modules/indexResource";
import {
fetchNamespaceStrategiesIfNeeded,
getFetchNamespaceStrategiesFailure, getNamespaceStrategies, isFetchNamespaceStrategiesPending
} from "../../admin/modules/namespaceStrategies";
type Props = {
repositoryTypes: RepositoryType[],
namespaceStrategies: NamespaceStrategies,
pageLoading: boolean,
createLoading: boolean,
error: Error,
repoLink: string,
// dispatch functions
fetchNamespaceStrategiesIfNeeded: () => void,
fetchRepositoryTypesIfNeeded: () => void,
createRepo: (
link: string,
Repository,
callback: (repo: Repository) => void
) => void,
resetForm: () => void,
// context props
t: string => string,
history: History
};
class Create extends React.Component<Props> {
componentDidMount() {
this.props.resetForm();
this.props.fetchRepositoryTypesIfNeeded();
this.props.fetchNamespaceStrategiesIfNeeded();
}
repoCreated = (repo: Repository) => {
const { history } = this.props;
history.push("/repo/" + repo.namespace + "/" + repo.name);
};
render() {
const {
pageLoading,
createLoading,
repositoryTypes,
namespaceStrategies,
createRepo,
error
} = this.props;
const { t, repoLink } = this.props;
return (
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
loading={pageLoading}
error={error}
showContentOnError={true}
>
<RepositoryForm
repositoryTypes={repositoryTypes}
loading={createLoading}
namespaceStrategy={namespaceStrategies.current}
submitForm={repo => {
createRepo(repoLink, repo, (repo: Repository) =>
this.repoCreated(repo)
);
}}
/>
</Page>
);
}
}
const mapStateToProps = state => {
const repositoryTypes = getRepositoryTypes(state);
const namespaceStrategies = getNamespaceStrategies(state);
const pageLoading = isFetchRepositoryTypesPending(state)
|| isFetchNamespaceStrategiesPending(state);
const createLoading = isCreateRepoPending(state);
const error = getFetchRepositoryTypesFailure(state)
|| getCreateRepoFailure(state)
|| getFetchNamespaceStrategiesFailure(state);
const repoLink = getRepositoriesLink(state);
return {
repositoryTypes,
namespaceStrategies,
pageLoading,
createLoading,
error,
repoLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
},
createRepo: (
link: string,
repository: Repository,
callback: () => void
) => {
dispatch(createRepo(link, repository, callback));
},
resetForm: () => {
dispatch(createRepoReset());
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(Create));

View File

@@ -0,0 +1,115 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
import {
Subtitle,
DeleteButton,
confirmAlert,
ErrorNotification
} from "@scm-manager/ui-components";
import {
deleteRepo,
getDeleteRepoFailure,
isDeleteRepoPending
} from "../modules/repos";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { History } from "history";
type Props = {
loading: boolean,
error: Error,
repository: Repository,
confirmDialog?: boolean,
deleteRepo: (Repository, () => void) => void,
// context props
history: History,
t: string => string
};
class DeleteRepo extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleted = () => {
this.props.history.push("/repos/");
};
deleteRepo = () => {
this.props.deleteRepo(this.props.repository, this.deleted);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("deleteRepo.confirmAlert.title"),
message: t("deleteRepo.confirmAlert.message"),
buttons: [
{
label: t("deleteRepo.confirmAlert.submit"),
onClick: () => this.deleteRepo()
},
{
label: t("deleteRepo.confirmAlert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.repository._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteRepo;
if (!this.isDeletable()) {
return null;
}
return (
<>
<hr />
<Subtitle subtitle={t("deleteRepo.subtitle")} />
<ErrorNotification error={error} />
<div className="columns">
<div className="column">
<DeleteButton
label={t("deleteRepo.button")}
action={action}
loading={loading}
/>
</div>
</div>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { namespace, name } = ownProps.repository;
const loading = isDeleteRepoPending(state, namespace, name);
const error = getDeleteRepoFailure(state, namespace, name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
deleteRepo: (repo: Repository, callback: () => void) => {
dispatch(deleteRepo(repo, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(translate("repos")(DeleteRepo)));

View File

@@ -0,0 +1,108 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import RepositoryForm from "../components/form";
import DeleteRepo from "./DeleteRepo";
import type { Repository } from "@scm-manager/ui-types";
import {
modifyRepo,
isModifyRepoPending,
getModifyRepoFailure,
modifyRepoReset
} from "../modules/repos";
import type { History } from "history";
import { ErrorNotification } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
loading: boolean,
error: Error,
modifyRepo: (Repository, () => void) => void,
modifyRepoReset: Repository => void,
// context props
repository: Repository,
history: History,
match: any
};
class EditRepo extends React.Component<Props> {
componentDidMount() {
const { modifyRepoReset, repository } = this.props;
modifyRepoReset(repository);
}
repoModified = () => {
const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.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 } = this.props;
const url = this.matchedUrl();
const extensionProps = {
repository,
url
};
return (
<div>
<ErrorNotification error={error} />
<RepositoryForm
repository={this.props.repository}
loading={loading}
submitForm={repo => {
this.props.modifyRepo(repo, this.repoModified);
}}
/>
<ExtensionPoint
name="repo-config.route"
props={extensionProps}
renderAll={true}
/>
<DeleteRepo repository={repository} />
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
modifyRepo: (repo: Repository, callback: () => void) => {
dispatch(modifyRepo(repo, callback));
},
modifyRepoReset: (repo: Repository) => {
dispatch(modifyRepoReset(repo));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(EditRepo));

View File

@@ -0,0 +1,168 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import type { History } from "history";
import { withRouter } from "react-router-dom";
import type { RepositoryCollection } from "@scm-manager/ui-types";
import {
fetchReposByPage,
getFetchReposFailure,
getRepositoryCollection,
isAbleToCreateRepos,
isFetchReposPending
} from "../modules/repos";
import {
Page,
PageActions,
OverviewPageActions,
CreateButton,
Notification,
LinkPaginator,
urls
} from "@scm-manager/ui-components";
import RepositoryList from "../components/list";
import { getRepositoriesLink } from "../../modules/indexResource";
type Props = {
loading: boolean,
error: Error,
showCreateButton: boolean,
collection: RepositoryCollection,
page: number,
reposLink: string,
// context props
t: string => string,
history: History,
location: any,
// dispatched functions
fetchReposByPage: (link: string, page: number, filter?: string) => void
};
class Overview extends React.Component<Props> {
componentDidMount() {
const { fetchReposByPage, reposLink, page, location } = this.props;
fetchReposByPage(
reposLink,
page,
urls.getQueryStringFromLocation(location)
);
}
componentDidUpdate = (prevProps: Props) => {
const {
loading,
collection,
page,
reposLink,
location,
fetchReposByPage
} = this.props;
if (collection && page && !loading) {
const statePage: number = collection.page + 1;
if (page !== statePage || prevProps.location.search !== location.search) {
fetchReposByPage(
reposLink,
page,
urls.getQueryStringFromLocation(location)
);
}
}
};
render() {
const { error, loading, showCreateButton, t } = this.props;
return (
<Page
title={t("overview.title")}
subtitle={t("overview.subtitle")}
loading={loading}
error={error}
>
{this.renderOverview()}
<PageActions>
<OverviewPageActions
showCreateButton={showCreateButton}
link="repos"
label={t("overview.createButton")}
/>
</PageActions>
</Page>
);
}
renderRepositoryList() {
const { collection, page, location, t } = this.props;
if (collection._embedded && collection._embedded.repositories.length > 0) {
return (
<>
<RepositoryList repositories={collection._embedded.repositories} />
<LinkPaginator
collection={collection}
page={page}
filter={urls.getQueryStringFromLocation(location)}
/>
</>
);
}
return (
<Notification type="info">{t("overview.noRepositories")}</Notification>
);
}
renderOverview() {
const { collection } = this.props;
if (collection) {
return (
<>
{this.renderRepositoryList()}
{this.renderCreateButton()}
</>
);
}
return null;
}
renderCreateButton() {
const { showCreateButton, t } = this.props;
if (showCreateButton) {
return (
<CreateButton label={t("overview.createButton")} link="/repos/create" />
);
}
return null;
}
}
const mapStateToProps = (state, ownProps) => {
const { match } = ownProps;
const collection = getRepositoryCollection(state);
const loading = isFetchReposPending(state);
const error = getFetchReposFailure(state);
const page = urls.getPageFromMatch(match);
const showCreateButton = isAbleToCreateRepos(state);
const reposLink = getRepositoriesLink(state);
return {
collection,
loading,
error,
page,
showCreateButton,
reposLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchReposByPage: (link: string, page: number, filter?: string) => {
dispatch(fetchReposByPage(link, page, filter));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(withRouter(Overview)));

View File

@@ -0,0 +1,317 @@
//@flow
import React from "react";
import {
fetchRepoByName,
getFetchRepoFailure,
getRepository,
isFetchRepoPending
} from "../modules/repos";
import { connect } from "react-redux";
import { Redirect, Route, Switch } from "react-router-dom";
import type { Repository } from "@scm-manager/ui-types";
import {
ErrorPage,
Loading,
Navigation,
NavLink,
Page,
Section,
SubNavigation
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails";
import EditRepo from "./EditRepo";
import BranchesOverview from "../branches/containers/BranchesOverview";
import CreateBranch from "../branches/containers/CreateBranch";
import Permissions from "../permissions/containers/Permissions";
import type { History } from "history";
import EditRepoNavLink from "../components/EditRepoNavLink";
import BranchRoot from "../branches/containers/BranchRoot";
import ChangesetsRoot from "./ChangesetsRoot";
import ChangesetView from "./ChangesetView";
import PermissionsNavLink from "../components/PermissionsNavLink";
import Sources from "../sources/containers/Sources";
import RepositoryNavLink from "../components/RepositoryNavLink";
import { getLinks, getRepositoriesLink } from "../../modules/indexResource";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
namespace: string,
name: string,
repository: Repository,
loading: boolean,
error: Error,
repoLink: string,
indexLinks: Object,
// dispatch functions
fetchRepoByName: (link: string, namespace: string, name: string) => void,
// context props
t: string => string,
history: History,
match: any
};
class RepositoryRoot extends React.Component<Props> {
componentDidMount() {
const { fetchRepoByName, namespace, name, repoLink } = this.props;
fetchRepoByName(repoLink, namespace, name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
matchesBranches = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}/branch/.+/info`);
return route.location.pathname.match(regex);
};
matchesChangesets = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}(/branch)?/?[^/]*/changesets?.*`);
return route.location.pathname.match(regex);
};
render() {
const { loading, error, indexLinks, repository, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("repositoryRoot.errorTitle")}
subtitle={t("repositoryRoot.errorSubtitle")}
error={error}
/>
);
}
if (!repository || loading) {
return <Loading />;
}
const url = this.matchedUrl();
const extensionProps = {
repository,
url,
indexLinks
};
const redirectUrlFactory = binder.getExtension(
"repository.redirect",
this.props
);
let redirectedUrl;
if (redirectUrlFactory) {
redirectedUrl = url + redirectUrlFactory(this.props);
} else {
redirectedUrl = url + "/info";
}
return (
<Page title={repository.namespace + "/" + repository.name}>
<div className="columns">
<div className="column is-three-quarters">
<Switch>
<Redirect exact from={this.props.match.url} to={redirectedUrl} />
<Route
path={`${url}/info`}
exact
component={() => <RepositoryDetails repository={repository} />}
/>
<Route
path={`${url}/settings/general`}
component={() => <EditRepo repository={repository} />}
/>
<Route
path={`${url}/settings/permissions`}
render={() => (
<Permissions
namespace={this.props.repository.namespace}
repoName={this.props.repository.name}
/>
)}
/>
<Route
exact
path={`${url}/changeset/:id`}
render={() => <ChangesetView repository={repository} />}
/>
<Route
path={`${url}/sources`}
exact={true}
render={() => (
<Sources repository={repository} baseUrl={`${url}/sources`} />
)}
/>
<Route
path={`${url}/sources/:revision/:path*`}
render={() => (
<Sources repository={repository} baseUrl={`${url}/sources`} />
)}
/>
<Route
path={`${url}/changesets`}
render={() => (
<ChangesetsRoot
repository={repository}
baseUrlWithBranch={`${url}/branch`}
baseUrlWithoutBranch={`${url}/changesets`}
/>
)}
/>
<Route
path={`${url}/branch/:branch/changesets`}
render={() => (
<ChangesetsRoot
repository={repository}
baseUrlWithBranch={`${url}/branch`}
baseUrlWithoutBranch={`${url}/changesets`}
/>
)}
/>
<Route
path={`${url}/branch/:branch`}
render={() => (
<BranchRoot
repository={repository}
baseUrl={`${url}/branch`}
/>
)}
/>
<Route
path={`${url}/branches`}
exact={true}
render={() => (
<BranchesOverview
repository={repository}
baseUrl={`${url}/branch`}
/>
)}
/>
<Route
path={`${url}/branches/create`}
render={() => <CreateBranch repository={repository} />}
/>
<ExtensionPoint
name="repository.route"
props={extensionProps}
renderAll={true}
/>
</Switch>
</div>
<div className="column">
<Navigation>
<Section label={t("repositoryRoot.menu.navigationLabel")}>
<ExtensionPoint
name="repository.navigation.topLevel"
props={extensionProps}
renderAll={true}
/>
<NavLink
to={`${url}/info`}
icon="fas fa-info-circle"
label={t("repositoryRoot.menu.informationNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="branches"
to={`${url}/branches/`}
icon="fas fa-code-branch"
label={t("repositoryRoot.menu.branchesNavLink")}
activeWhenMatch={this.matchesBranches}
activeOnlyWhenExact={false}
/>
<RepositoryNavLink
repository={repository}
linkName="changesets"
to={`${url}/changesets/`}
icon="fas fa-exchange-alt"
label={t("repositoryRoot.menu.historyNavLink")}
activeWhenMatch={this.matchesChangesets}
activeOnlyWhenExact={false}
/>
<RepositoryNavLink
repository={repository}
linkName="sources"
to={`${url}/sources`}
icon="fas fa-code"
label={t("repositoryRoot.menu.sourcesNavLink")}
activeOnlyWhenExact={false}
/>
<ExtensionPoint
name="repository.navigation"
props={extensionProps}
renderAll={true}
/>
<SubNavigation
to={`${url}/settings/general`}
label={t("repositoryRoot.menu.settingsNavLink")}
>
<EditRepoNavLink
repository={repository}
editUrl={`${url}/settings/general`}
/>
<PermissionsNavLink
permissionUrl={`${url}/settings/permissions`}
repository={repository}
/>
<ExtensionPoint
name="repository.setting"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</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);
const repoLink = getRepositoriesLink(state);
const indexLinks = getLinks(state);
return {
namespace,
name,
repository,
loading,
error,
repoLink,
indexLinks
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepoByName(link, namespace, name));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(RepositoryRoot));

View File

@@ -0,0 +1,400 @@
// @flow
import {
FAILURE_SUFFIX,
PENDING_SUFFIX,
SUCCESS_SUFFIX
} from "../../modules/types";
import { apiClient, urls } from "@scm-manager/ui-components";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import type {
Action,
Branch,
PagedCollection,
Repository
} from "@scm-manager/ui-types";
export const FETCH_CHANGESETS = "scm/repos/FETCH_CHANGESETS";
export const FETCH_CHANGESETS_PENDING = `${FETCH_CHANGESETS}_${PENDING_SUFFIX}`;
export const FETCH_CHANGESETS_SUCCESS = `${FETCH_CHANGESETS}_${SUCCESS_SUFFIX}`;
export const FETCH_CHANGESETS_FAILURE = `${FETCH_CHANGESETS}_${FAILURE_SUFFIX}`;
export const FETCH_CHANGESET = "scm/repos/FETCH_CHANGESET";
export const FETCH_CHANGESET_PENDING = `${FETCH_CHANGESET}_${PENDING_SUFFIX}`;
export const FETCH_CHANGESET_SUCCESS = `${FETCH_CHANGESET}_${SUCCESS_SUFFIX}`;
export const FETCH_CHANGESET_FAILURE = `${FETCH_CHANGESET}_${FAILURE_SUFFIX}`;
// actions
//TODO: Content type
export function fetchChangesetIfNeeded(repository: Repository, id: string) {
return (dispatch: any, getState: any) => {
if (shouldFetchChangeset(getState(), repository, id)) {
return dispatch(fetchChangeset(repository, id));
}
};
}
export function fetchChangeset(repository: Repository, id: string) {
return function(dispatch: any) {
dispatch(fetchChangesetPending(repository, id));
return apiClient
.get(createChangesetUrl(repository, id))
.then(response => response.json())
.then(data => dispatch(fetchChangesetSuccess(data, repository, id)))
.catch(err => {
dispatch(fetchChangesetFailure(repository, id, err));
});
};
}
function createChangesetUrl(repository: Repository, id: string) {
return urls.concat(repository._links.changesets.href, id);
}
export function fetchChangesetPending(
repository: Repository,
id: string
): Action {
return {
type: FETCH_CHANGESET_PENDING,
itemId: createChangesetItemId(repository, id)
};
}
export function fetchChangesetSuccess(
changeset: any,
repository: Repository,
id: string
): Action {
return {
type: FETCH_CHANGESET_SUCCESS,
payload: { changeset, repository, id },
itemId: createChangesetItemId(repository, id)
};
}
function fetchChangesetFailure(
repository: Repository,
id: string,
error: Error
): Action {
return {
type: FETCH_CHANGESET_FAILURE,
payload: {
repository,
id,
error
},
itemId: createChangesetItemId(repository, id)
};
}
export function fetchChangesets(
repository: Repository,
branch?: Branch,
page?: number
) {
const link = createChangesetsLink(repository, branch, page);
return function(dispatch: any) {
dispatch(fetchChangesetsPending(repository, branch));
return apiClient
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchChangesetsSuccess(repository, branch, data));
})
.catch(cause => {
dispatch(fetchChangesetsFailure(repository, branch, cause));
});
};
}
function createChangesetsLink(
repository: Repository,
branch?: Branch,
page?: number
) {
let link = repository._links.changesets.href;
if (branch) {
link = branch._links.history.href;
}
if (page) {
link = link + `?page=${page - 1}`;
}
return link;
}
export function fetchChangesetsPending(
repository: Repository,
branch?: Branch
): Action {
const itemId = createItemId(repository, branch);
return {
type: FETCH_CHANGESETS_PENDING,
itemId
};
}
export function fetchChangesetsSuccess(
repository: Repository,
branch?: Branch,
changesets: any
): Action {
return {
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId: createItemId(repository, branch)
};
}
function fetchChangesetsFailure(
repository: Repository,
branch?: Branch,
error: Error
): Action {
return {
type: FETCH_CHANGESETS_FAILURE,
payload: {
repository,
error,
branch
},
itemId: createItemId(repository, branch)
};
}
function createChangesetItemId(repository: Repository, id: string) {
const { namespace, name } = repository;
return namespace + "/" + name + "/" + id;
}
function createItemId(repository: Repository, branch?: Branch): string {
const { namespace, name } = repository;
let itemId = namespace + "/" + name;
if (branch) {
itemId = itemId + "/" + branch.name;
}
return itemId;
}
// reducer
export default function reducer(
state: any = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_CHANGESET_SUCCESS:
const _key = createItemId(payload.repository);
let _oldByIds = {};
if (state[_key] && state[_key].byId) {
_oldByIds = state[_key].byId;
}
const changeset = payload.changeset;
return {
...state,
[_key]: {
...state[_key],
byId: {
..._oldByIds,
[changeset.id]: changeset
}
}
};
case FETCH_CHANGESETS_SUCCESS:
const changesets = payload.changesets._embedded.changesets;
const changesetIds = changesets.map(c => c.id);
const key = action.itemId;
if (!key) {
return state;
}
const repoId = createItemId(payload.repository);
let oldState = {};
if (state[repoId]) {
oldState = state[repoId];
}
const branchName = payload.branch ? payload.branch.name : "";
const byIds = extractChangesetsByIds(changesets);
return {
...state,
[repoId]: {
byId: {
...oldState.byId,
...byIds
},
byBranch: {
...oldState.byBranch,
[branchName]: {
entries: changesetIds,
entry: {
page: payload.changesets.page,
pageTotal: payload.changesets.pageTotal,
_links: payload.changesets._links
}
}
}
}
};
default:
return state;
}
}
function extractChangesetsByIds(changesets: any) {
const changesetsByIds = {};
for (let changeset of changesets) {
changesetsByIds[changeset.id] = changeset;
}
return changesetsByIds;
}
//selectors
export function getChangesets(
state: Object,
repository: Repository,
branch?: Branch
) {
const repoKey = createItemId(repository);
const stateRoot = state.changesets[repoKey];
if (!stateRoot || !stateRoot.byBranch) {
return null;
}
const branchName = branch ? branch.name : "";
const changesets = stateRoot.byBranch[branchName];
if (!changesets) {
return null;
}
return changesets.entries.map((id: string) => {
return stateRoot.byId[id];
});
}
export function getChangeset(
state: Object,
repository: Repository,
id: string
) {
const key = createItemId(repository);
const changesets =
state.changesets && state.changesets[key]
? state.changesets[key].byId
: null;
if (changesets != null && changesets[id]) {
return changesets[id];
}
return null;
}
export function shouldFetchChangeset(
state: Object,
repository: Repository,
id: string
) {
if (getChangeset(state, repository, id)) {
return false;
}
return true;
}
export function isFetchChangesetPending(
state: Object,
repository: Repository,
id: string
) {
return isPending(
state,
FETCH_CHANGESET,
createChangesetItemId(repository, id)
);
}
export function getFetchChangesetFailure(
state: Object,
repository: Repository,
id: string
) {
return getFailure(
state,
FETCH_CHANGESET,
createChangesetItemId(repository, id)
);
}
export function isFetchChangesetsPending(
state: Object,
repository: Repository,
branch?: Branch
) {
return isPending(state, FETCH_CHANGESETS, createItemId(repository, branch));
}
export function getFetchChangesetsFailure(
state: Object,
repository: Repository,
branch?: Branch
) {
return getFailure(state, FETCH_CHANGESETS, createItemId(repository, branch));
}
const selectList = (state: Object, repository: Repository, branch?: Branch) => {
const repoId = createItemId(repository);
const branchName = branch ? branch.name : "";
if (state.changesets[repoId]) {
const repoState = state.changesets[repoId];
if (repoState.byBranch && repoState.byBranch[branchName]) {
return repoState.byBranch[branchName];
}
}
return {};
};
const selectListEntry = (
state: Object,
repository: Repository,
branch?: Branch
): Object => {
const list = selectList(state, repository, branch);
if (list.entry) {
return list.entry;
}
return {};
};
export const selectListAsCollection = (
state: Object,
repository: Repository,
branch?: Branch
): PagedCollection => {
return selectListEntry(state, repository, branch);
};

View File

@@ -0,0 +1,620 @@
// @flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_CHANGESET,
FETCH_CHANGESET_FAILURE,
FETCH_CHANGESET_PENDING,
FETCH_CHANGESET_SUCCESS,
FETCH_CHANGESETS,
FETCH_CHANGESETS_FAILURE,
FETCH_CHANGESETS_PENDING,
FETCH_CHANGESETS_SUCCESS,
fetchChangeset,
fetchChangesetIfNeeded,
fetchChangesets,
fetchChangesetsSuccess,
fetchChangesetSuccess,
getChangeset,
getChangesets,
getFetchChangesetFailure,
getFetchChangesetsFailure,
isFetchChangesetPending,
isFetchChangesetsPending,
selectListAsCollection,
shouldFetchChangeset
} from "./changesets";
const branch = {
name: "specific",
revision: "123",
_links: {
history: {
href:
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets"
}
}
};
const repository = {
namespace: "foo",
name: "bar",
type: "GIT",
_links: {
self: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar"
},
changesets: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets"
},
branches: {
href:
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/branches"
}
}
};
const changesets = {};
describe("changesets", () => {
describe("fetching of changesets", () => {
const DEFAULT_BRANCH_URL =
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets";
const SPECIFIC_BRANCH_URL =
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
const changesetId = "aba876c0625d90a6aff1494f3d161aaa7008b958";
it("should fetch changeset", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, "{}");
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/" + changesetId
},
{
type: FETCH_CHANGESET_SUCCESS,
payload: {
changeset: {},
id: changesetId,
repository: repository
},
itemId: "foo/bar/" + changesetId
}
];
const store = mockStore({});
return store
.dispatch(fetchChangeset(repository, changesetId))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching changeset on error", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, 500);
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/" + changesetId
}
];
const store = mockStore({});
return store
.dispatch(fetchChangeset(repository, changesetId))
.then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESET_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fetch changeset if needed", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id3", "{}");
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/id3"
},
{
type: FETCH_CHANGESET_SUCCESS,
payload: {
changeset: {},
id: "id3",
repository: repository
},
itemId: "foo/bar/id3"
}
];
const store = mockStore({});
return store
.dispatch(fetchChangesetIfNeeded(repository, "id3"))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should not fetch changeset if not needed", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id1", 500);
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const store = mockStore(state);
return expect(
store.dispatch(fetchChangesetIfNeeded(repository, "id1"))
).toEqual(undefined);
});
it("should fetch changesets for default branch", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL, "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch changesets for specific branch", () => {
const itemId = "foo/bar/specific";
fetchMock.getOnce(SPECIFIC_BRANCH_URL, "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching changesets on error", () => {
const itemId = "foo/bar";
fetchMock.getOnce(DEFAULT_BRANCH_URL, 500);
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESETS_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fail fetching changesets for specific branch on error", () => {
const itemId = "foo/bar/specific";
fetchMock.getOnce(SPECIFIC_BRANCH_URL, 500);
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESETS_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fetch changesets by page", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "?page=4", "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
const store = mockStore({});
return store
.dispatch(fetchChangesets(repository, undefined, 5))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch changesets by branch and page", () => {
fetchMock.getOnce(SPECIFIC_BRANCH_URL + "?page=4", "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar/specific"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId: "foo/bar/specific"
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch, 5)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
describe("changesets reducer", () => {
const responseBody = {
page: 1,
pageTotal: 10,
_links: {},
_embedded: {
changesets: [
{ id: "changeset1", author: { mail: "z@phod.com", name: "zaphod" } },
{ id: "changeset2", description: "foo" },
{ id: "changeset3", description: "bar" }
],
_embedded: {
tags: [],
branches: [],
parents: []
}
}
};
it("should set state to received changesets", () => {
const newState = reducer(
{},
fetchChangesetsSuccess(repository, undefined, responseBody)
);
expect(newState).toBeDefined();
expect(newState["foo/bar"].byId["changeset1"].author.mail).toEqual(
"z@phod.com"
);
expect(newState["foo/bar"].byId["changeset2"].description).toEqual("foo");
expect(newState["foo/bar"].byId["changeset3"].description).toEqual("bar");
expect(newState["foo/bar"].byBranch[""]).toEqual({
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["changeset1", "changeset2", "changeset3"]
});
});
it("should store the changeset list to branch", () => {
const newState = reducer(
{},
fetchChangesetsSuccess(repository, branch, responseBody)
);
expect(newState["foo/bar"].byId["changeset1"]).toBeDefined();
expect(newState["foo/bar"].byBranch["specific"].entries).toEqual([
"changeset1",
"changeset2",
"changeset3"
]);
});
it("should not remove existing changesets", () => {
const state = {
"foo/bar": {
byId: {
id2: { id: "id2" },
id1: { id: "id1" }
},
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
};
const newState = reducer(
state,
fetchChangesetsSuccess(repository, undefined, responseBody)
);
const fooBar = newState["foo/bar"];
expect(fooBar.byBranch[""].entries).toEqual([
"changeset1",
"changeset2",
"changeset3"
]);
expect(fooBar.byId["id2"]).toEqual({ id: "id2" });
expect(fooBar.byId["id1"]).toEqual({ id: "id1" });
});
const responseBodySingleChangeset = {
id: "id3",
author: {
mail: "z@phod.com",
name: "zaphod"
},
date: "2018-09-13T08:46:22Z",
description: "added testChangeset",
_links: {},
_embedded: {
tags: [],
branches: []
}
};
it("should add changeset to state", () => {
const newState = reducer(
{
"foo/bar": {
byId: {
"id2": {
id: "id2",
author: { mail: "mail@author.com", name: "author" }
}
},
list: {
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id2"]
}
}
},
fetchChangesetSuccess(responseBodySingleChangeset, repository, "id3")
);
expect(newState).toBeDefined();
expect(newState["foo/bar"].byId["id3"].description).toEqual(
"added testChangeset"
);
expect(newState["foo/bar"].byId["id3"].author.mail).toEqual("z@phod.com");
expect(newState["foo/bar"].byId["id2"]).toBeDefined();
expect(newState["foo/bar"].byId["id3"]).toBeDefined();
expect(newState["foo/bar"].list).toEqual({
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id2"]
});
});
});
describe("changeset selectors", () => {
const error = new Error("Something went wrong");
it("should return changeset", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = getChangeset(state, repository, "id1");
expect(result).toEqual({ id: "id1" });
});
it("should return null if changeset does not exist", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = getChangeset(state, repository, "id3");
expect(result).toEqual(null);
});
it("should return true if changeset does not exist", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = shouldFetchChangeset(state, repository, "id3");
expect(result).toEqual(true);
});
it("should return false if changeset exists", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = shouldFetchChangeset(state, repository, "id2");
expect(result).toEqual(false);
});
it("should return true, when fetching changeset is pending", () => {
const state = {
pending: {
[FETCH_CHANGESET + "/foo/bar/id1"]: true
}
};
expect(isFetchChangesetPending(state, repository, "id1")).toBeTruthy();
});
it("should return false, when fetching changeset is not pending", () => {
expect(isFetchChangesetPending({}, repository, "id1")).toEqual(false);
});
it("should return error if fetching changeset failed", () => {
const state = {
failure: {
[FETCH_CHANGESET + "/foo/bar/id1"]: error
}
};
expect(getFetchChangesetFailure(state, repository, "id1")).toEqual(error);
});
it("should return false if fetching changeset did not fail", () => {
expect(getFetchChangesetFailure({}, repository, "id1")).toBeUndefined();
});
it("should get all changesets for a given repository", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id2: { id: "id2" },
id1: { id: "id1" }
},
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
}
};
const result = getChangesets(state, repository);
expect(result).toEqual([{ id: "id1" }, { id: "id2" }]);
});
it("should return true, when fetching changesets is pending", () => {
const state = {
pending: {
[FETCH_CHANGESETS + "/foo/bar"]: true
}
};
expect(isFetchChangesetsPending(state, repository)).toBeTruthy();
});
it("should return false, when fetching changesets is not pending", () => {
expect(isFetchChangesetsPending({}, repository)).toEqual(false);
});
it("should return error if fetching changesets failed", () => {
const state = {
failure: {
[FETCH_CHANGESETS + "/foo/bar"]: error
}
};
expect(getFetchChangesetsFailure(state, repository)).toEqual(error);
});
it("should return false if fetching changesets did not fail", () => {
expect(getFetchChangesetsFailure({}, repository)).toBeUndefined();
});
it("should return list as collection for the default branch", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id2: { id: "id2" },
id1: { id: "id1" }
},
byBranch: {
"": {
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id1", "id2"]
}
}
}
}
};
const collection = selectListAsCollection(state, repository);
expect(collection.page).toBe(1);
expect(collection.pageTotal).toBe(10);
});
});
});

View File

@@ -0,0 +1,486 @@
// @flow
import { apiClient } from "@scm-manager/ui-components";
import * as types from "../../modules/types";
import type {
Action,
Repository,
RepositoryCollection
} from "@scm-manager/ui-types";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
export const FETCH_REPOS = "scm/repos/FETCH_REPOS";
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}`;
export const CREATE_REPO = "scm/repos/CREATE_REPO";
export const CREATE_REPO_PENDING = `${CREATE_REPO}_${types.PENDING_SUFFIX}`;
export const CREATE_REPO_SUCCESS = `${CREATE_REPO}_${types.SUCCESS_SUFFIX}`;
export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`;
export const CREATE_REPO_RESET = `${CREATE_REPO}_${types.RESET_SUFFIX}`;
export const MODIFY_REPO = "scm/repos/MODIFY_REPO";
export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`;
export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`;
export const MODIFY_REPO_RESET = `${MODIFY_REPO}_${types.RESET_SUFFIX}`;
export const DELETE_REPO = "scm/repos/DELETE_REPO";
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 CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
// fetch repos
const SORT_BY = "sortBy=namespaceAndName";
export function fetchRepos(link: string) {
return fetchReposByLink(link);
}
export function fetchReposByPage(link: string, page: number, filter?: string) {
if (filter) {
return fetchReposByLink(
`${link}?page=${page - 1}&q=${decodeURIComponent(filter)}`
);
}
return fetchReposByLink(`${link}?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(url)
.then(response => response.json())
.then(repositories => {
dispatch(fetchReposSuccess(repositories));
})
.catch(err => {
dispatch(fetchReposFailure(err));
});
};
}
export function fetchReposPending(): Action {
return {
type: FETCH_REPOS_PENDING
};
}
export function fetchReposSuccess(repositories: RepositoryCollection): Action {
return {
type: FETCH_REPOS_SUCCESS,
payload: repositories
};
}
export function fetchReposFailure(err: Error): Action {
return {
type: FETCH_REPOS_FAILURE,
payload: err
};
}
// fetch repo
export function fetchRepoByLink(repo: Repository) {
return fetchRepo(repo._links.self.href, repo.namespace, repo.name);
}
export function fetchRepoByName(link: string, namespace: string, name: string) {
const repoUrl = link.endsWith("/") ? link : link + "/";
return fetchRepo(`${repoUrl}${namespace}/${name}`, namespace, name);
}
function fetchRepo(link: string, namespace: string, name: string) {
return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name));
return apiClient
.get(link)
.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
};
}
// create repo
export function createRepo(
link: string,
repository: Repository,
callback?: (repo: Repository) => void
) {
return function(dispatch: any) {
dispatch(createRepoPending());
return apiClient
.post(link, repository, CONTENT_TYPE)
.then(response => {
const location = response.headers.get("Location");
dispatch(createRepoSuccess());
return apiClient.get(location);
})
.then(response => response.json())
.then(response => {
if (callback) {
callback(response);
}
})
.catch(err => {
dispatch(createRepoFailure(err));
});
};
}
export function createRepoPending(): Action {
return {
type: CREATE_REPO_PENDING
};
}
export function createRepoSuccess(): Action {
return {
type: CREATE_REPO_SUCCESS
};
}
export function createRepoFailure(err: Error): Action {
return {
type: CREATE_REPO_FAILURE,
payload: err
};
}
export function createRepoReset(): Action {
return {
type: CREATE_REPO_RESET
};
}
// modify
export function modifyRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.put(repository._links.update.href, repository, CONTENT_TYPE)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.then(() => {
dispatch(fetchRepoByLink(repository));
})
.catch(error => {
dispatch(modifyRepoFailure(repository, error));
});
};
}
export function modifyRepoPending(repository: Repository): Action {
return {
type: MODIFY_REPO_PENDING,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function modifyRepoSuccess(repository: Repository): Action {
return {
type: MODIFY_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function modifyRepoFailure(
repository: Repository,
error: Error
): Action {
return {
type: MODIFY_REPO_FAILURE,
payload: { error, repository },
itemId: createIdentifier(repository)
};
}
export function modifyRepoReset(repository: Repository): Action {
return {
type: MODIFY_REPO_RESET,
payload: { repository },
itemId: createIdentifier(repository)
};
}
// delete
export function deleteRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(deleteRepoPending(repository));
return apiClient
.delete(repository._links.delete.href)
.then(() => {
dispatch(deleteRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(deleteRepoFailure(repository, err));
});
};
}
export function deleteRepoPending(repository: Repository): Action {
return {
type: DELETE_REPO_PENDING,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function deleteRepoSuccess(repository: Repository): Action {
return {
type: DELETE_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function deleteRepoFailure(
repository: Repository,
error: Error
): Action {
return {
type: DELETE_REPO_FAILURE,
payload: {
error,
repository
},
itemId: createIdentifier(repository)
};
}
// 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 = createIdentifier(repository);
names.push(identifier);
byNames[identifier] = repository;
}
return {
list: {
...repositoryCollection,
_embedded: {
repositories: names
}
},
byNames: byNames
};
}
const reducerByNames = (state: Object, repository: Repository) => {
const identifier = createIdentifier(repository);
return {
...state,
byNames: {
...state.byNames,
[identifier]: repository
}
};
};
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_REPOS_SUCCESS:
return normalizeByNamespaceAndName(action.payload);
case FETCH_REPO_SUCCESS:
return reducerByNames(state, action.payload);
default:
return state;
}
}
// selectors
export function getRepositoryCollection(state: Object) {
if (state.repos && state.repos.list && state.repos.byNames) {
const repositories = [];
for (let repositoryName of state.repos.list._embedded.repositories) {
repositories.push(state.repos.byNames[repositoryName]);
}
return {
...state.repos.list,
_embedded: {
repositories
}
};
}
}
export function isFetchReposPending(state: Object) {
return isPending(state, FETCH_REPOS);
}
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);
}
export function isAbleToCreateRepos(state: Object) {
return !!(
state.repos &&
state.repos.list &&
state.repos.list._links &&
state.repos.list._links.create
);
}
export function isCreateRepoPending(state: Object) {
return isPending(state, CREATE_REPO);
}
export function getCreateRepoFailure(state: Object) {
return getFailure(state, CREATE_REPO);
}
export function isModifyRepoPending(
state: Object,
namespace: string,
name: string
) {
return isPending(state, MODIFY_REPO, namespace + "/" + name);
}
export function getModifyRepoFailure(
state: Object,
namespace: string,
name: string
) {
return getFailure(state, MODIFY_REPO, namespace + "/" + name);
}
export function isDeleteRepoPending(
state: Object,
namespace: string,
name: string
) {
return isPending(state, DELETE_REPO, namespace + "/" + name);
}
export function getDeleteRepoFailure(
state: Object,
namespace: string,
name: string
) {
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;
}

View File

@@ -0,0 +1,858 @@
// @flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_REPOS_PENDING,
FETCH_REPOS_SUCCESS,
fetchRepos,
FETCH_REPOS_FAILURE,
fetchReposSuccess,
getRepositoryCollection,
FETCH_REPOS,
isFetchReposPending,
getFetchReposFailure,
fetchReposByLink,
fetchReposByPage,
FETCH_REPO,
fetchRepoByLink,
fetchRepoByName,
FETCH_REPO_PENDING,
FETCH_REPO_SUCCESS,
FETCH_REPO_FAILURE,
fetchRepoSuccess,
getRepository,
isFetchRepoPending,
getFetchRepoFailure,
CREATE_REPO_PENDING,
CREATE_REPO_SUCCESS,
createRepo,
CREATE_REPO_FAILURE,
isCreateRepoPending,
CREATE_REPO,
getCreateRepoFailure,
isAbleToCreateRepos,
DELETE_REPO,
DELETE_REPO_SUCCESS,
deleteRepo,
DELETE_REPO_PENDING,
DELETE_REPO_FAILURE,
isDeleteRepoPending,
getDeleteRepoFailure,
modifyRepo,
MODIFY_REPO_PENDING,
MODIFY_REPO_SUCCESS,
MODIFY_REPO_FAILURE,
MODIFY_REPO,
isModifyRepoPending,
getModifyRepoFailure,
getPermissionsLink
} from "./repos";
import type { Repository, RepositoryCollection } from "@scm-manager/ui-types";
const hitchhikerPuzzle42: Repository = {
contact: "fourtytwo@hitchhiker.com",
creationDate: "2018-07-31T08:58:45.961Z",
description: "the answer to life the universe and everything",
namespace: "hitchhiker",
name: "puzzle42",
type: "svn",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
update: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
permissions: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/permissions/"
},
tags: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/tags/"
},
branches: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/branches/"
},
changesets: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/changesets/"
},
sources: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/sources/"
}
}
};
const hitchhikerRestatend: Repository = {
contact: "restatend@hitchhiker.com",
creationDate: "2018-07-31T08:58:32.803Z",
description: "restaurant at the end of the universe",
namespace: "hitchhiker",
name: "restatend",
type: "git",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
update: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
permissions: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend/permissions/"
},
tags: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend/tags/"
},
branches: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend/branches/"
},
changesets: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend/changesets/"
},
sources: {
href:
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend/sources/"
}
}
};
const slartiFjords: Repository = {
contact: "slartibartfast@hitchhiker.com",
description: "My award-winning fjords from the Norwegian coast",
namespace: "slarti",
name: "fjords",
type: "hg",
creationDate: "2018-07-31T08:59:05.653Z",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
update: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
permissions: {
href:
"http://localhost:8081/api/v2/repositories/slarti/fjords/permissions/"
},
tags: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/"
},
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/"
}
}
};
const repositoryCollection: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
first: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
last: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/api/v2/repositories/"
}
},
_embedded: {
repositories: [hitchhikerPuzzle42, hitchhikerRestatend, slartiFjords]
}
};
const repositoryCollectionWithNames: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
first: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
last: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/api/v2/repositories/"
}
},
_embedded: {
repositories: [
"hitchhiker/puzzle42",
"hitchhiker/restatend",
"slarti/fjords"
]
}
};
describe("repos fetch", () => {
const URL = "repositories";
const REPOS_URL = "/api/v2/repositories";
const SORT = "sortBy=namespaceAndName";
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch repos", () => {
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(URL)).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 },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByPage(URL, 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(
"/api/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_WITH_SORT, {
status: 500
});
const store = mockStore({});
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);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully fetch repo slarti/fjords by name", () => {
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(fetchRepoByName(URL, "slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, if the request for slarti/fjords by name fails", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepoByName(URL, "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");
});
});
it("should successfully fetch repo slarti/fjords", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/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(fetchRepoByLink(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
const store = mockStore({});
return store.dispatch(fetchRepoByLink(slartiFjords)).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");
});
});
it("should successfully create repo slarti/fjords", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201,
headers: {
location: "repositories/slarti/fjords"
}
});
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: CREATE_REPO_PENDING
},
{
type: CREATE_REPO_SUCCESS
}
];
const store = mockStore({});
return store.dispatch(createRepo(URL, slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully create repo slarti/fjords and call the callback", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201,
headers: {
location: "repositories/slarti/fjords"
}
});
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
let callMe = "not yet";
const callback = (r: any) => {
expect(r).toEqual(slartiFjords);
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("should dispatch failure if server returns status code 500", () => {
fetchMock.postOnce(REPOS_URL, {
status: 500
});
const store = mockStore({});
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);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully delete repo slarti/fjords", () => {
fetchMock.delete(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 204
}
);
const expectedActions = [
{
type: DELETE_REPO_PENDING,
payload: slartiFjords,
itemId: "slarti/fjords"
},
{
type: DELETE_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully delete repo slarti/fjords and call the callback", () => {
fetchMock.delete(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 204
}
);
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("should disapatch failure on delete, if server returns status code 500", () => {
fetchMock.delete(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_REPO_PENDING);
expect(actions[1].type).toEqual(DELETE_REPO_FAILURE);
expect(actions[1].payload.repository).toBe(slartiFjords);
expect(actions[1].payload.error).toBeDefined();
});
});
it("should successfully modify slarti/fjords repo", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204
});
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
let editedFjords = { ...slartiFjords };
editedFjords.description = "coast of africa";
const store = mockStore({});
return store.dispatch(modifyRepo(editedFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
});
});
it("should successfully modify slarti/fjords repo and call the callback", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204
});
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
let editedFjords = { ...slartiFjords };
editedFjords.description = "coast of africa";
const store = mockStore({});
let called = false;
const callback = () => {
called = true;
};
return store.dispatch(modifyRepo(editedFjords, callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
expect(called).toBe(true);
});
});
it("should fail modifying on HTTP 500", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 500
});
let editedFjords = { ...slartiFjords };
editedFjords.description = "coast of africa";
const store = mockStore({});
return store.dispatch(modifyRepo(editedFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("repos 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 repositories by it's namespace and name on FETCH_REPOS_SUCCESS", () => {
const newState = reducer({}, fetchReposSuccess(repositoryCollection));
expect(newState.list.page).toBe(0);
expect(newState.list.pageTotal).toBe(1);
expect(newState.list._embedded.repositories).toEqual([
"hitchhiker/puzzle42",
"hitchhiker/restatend",
"slarti/fjords"
]);
expect(newState.byNames["hitchhiker/puzzle42"]).toBe(hitchhikerPuzzle42);
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", () => {
const state = {
repos: {
list: repositoryCollectionWithNames,
byNames: {
"hitchhiker/puzzle42": hitchhikerPuzzle42,
"hitchhiker/restatend": hitchhikerRestatend,
"slarti/fjords": slartiFjords
}
}
};
const collection = getRepositoryCollection(state);
expect(collection).toEqual(repositoryCollection);
});
it("should return true, when fetch repos is pending", () => {
const state = {
pending: {
[FETCH_REPOS]: true
}
};
expect(isFetchReposPending(state)).toEqual(true);
});
it("should return false, when fetch repos is not pending", () => {
expect(isFetchReposPending({})).toEqual(false);
});
it("should return error when fetch repos did fail", () => {
const state = {
failure: {
[FETCH_REPOS]: error
}
};
expect(getFetchReposFailure(state)).toEqual(error);
});
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 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: {
[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);
});
it("should return undefined when fetch repo did not fail", () => {
expect(getFetchRepoFailure({}, "slarti", "fjords")).toBe(undefined);
});
// create
it("should return true, when create repo is pending", () => {
const state = {
pending: {
[CREATE_REPO]: true
}
};
expect(isCreateRepoPending(state)).toEqual(true);
});
it("should return false, when create repo is not pending", () => {
expect(isCreateRepoPending({})).toEqual(false);
});
it("should return error when create repo did fail", () => {
const state = {
failure: {
[CREATE_REPO]: error
}
};
expect(getCreateRepoFailure(state)).toEqual(error);
});
it("should return undefined when create repo did not fail", () => {
expect(getCreateRepoFailure({})).toBe(undefined);
});
// modify
it("should return true, when modify repo is pending", () => {
const state = {
pending: {
[MODIFY_REPO + "/slarti/fjords"]: true
}
};
expect(isModifyRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when modify repo is not pending", () => {
expect(isModifyRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error, when modify repo failed", () => {
const state = {
failure: {
[MODIFY_REPO + "/slarti/fjords"]: error
}
};
expect(getModifyRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
it("should return undefined, when modify did not fail", () => {
expect(getModifyRepoFailure({}, "slarti", "fjords")).toBeUndefined();
});
// delete
it("should return true, when delete repo is pending", () => {
const state = {
pending: {
[DELETE_REPO + "/slarti/fjords"]: true
}
};
expect(isDeleteRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when delete repo is not pending", () => {
expect(isDeleteRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error when delete repo did fail", () => {
const state = {
failure: {
[DELETE_REPO + "/slarti/fjords"]: error
}
};
expect(getDeleteRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
it("should return undefined when delete repo did not fail", () => {
expect(getDeleteRepoFailure({}, "slarti", "fjords")).toBe(undefined);
});
it("should return true if the list contains the create link", () => {
const state = {
repos: {
list: repositoryCollection
}
};
expect(isAbleToCreateRepos(state)).toBe(true);
});
it("should return false, if create link is unavailable", () => {
const state = {
repos: {
list: {
_links: {}
}
}
};
expect(isAbleToCreateRepos(state)).toBe(false);
});
});

View File

@@ -0,0 +1,104 @@
// @flow
import * as types from "../../modules/types";
import type {
Action,
RepositoryType,
RepositoryTypeCollection
} from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
export const FETCH_REPOSITORY_TYPES = "scm/repos/FETCH_REPOSITORY_TYPES";
export const FETCH_REPOSITORY_TYPES_PENDING = `${FETCH_REPOSITORY_TYPES}_${
types.PENDING_SUFFIX
}`;
export const FETCH_REPOSITORY_TYPES_SUCCESS = `${FETCH_REPOSITORY_TYPES}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_REPOSITORY_TYPES_FAILURE = `${FETCH_REPOSITORY_TYPES}_${
types.FAILURE_SUFFIX
}`;
export function fetchRepositoryTypesIfNeeded() {
return function(dispatch: any, getState: () => Object) {
if (shouldFetchRepositoryTypes(getState())) {
return fetchRepositoryTypes(dispatch);
}
};
}
function fetchRepositoryTypes(dispatch: any) {
dispatch(fetchRepositoryTypesPending());
return apiClient
.get("repositoryTypes")
.then(response => response.json())
.then(repositoryTypes => {
dispatch(fetchRepositoryTypesSuccess(repositoryTypes));
})
.catch(error => {
dispatch(fetchRepositoryTypesFailure(error));
});
}
export function shouldFetchRepositoryTypes(state: Object) {
if (
isFetchRepositoryTypesPending(state) ||
getFetchRepositoryTypesFailure(state)
) {
return false;
}
return !(state.repositoryTypes && state.repositoryTypes.length > 0);
}
export function fetchRepositoryTypesPending(): Action {
return {
type: FETCH_REPOSITORY_TYPES_PENDING
};
}
export function fetchRepositoryTypesSuccess(
repositoryTypes: RepositoryTypeCollection
): Action {
return {
type: FETCH_REPOSITORY_TYPES_SUCCESS,
payload: repositoryTypes
};
}
export function fetchRepositoryTypesFailure(error: Error): Action {
return {
type: FETCH_REPOSITORY_TYPES_FAILURE,
payload: error
};
}
// reducers
export default function reducer(
state: RepositoryType[] = [],
action: Action = { type: "UNKNOWN" }
): RepositoryType[] {
if (action.type === FETCH_REPOSITORY_TYPES_SUCCESS && action.payload) {
return action.payload._embedded["repositoryTypes"];
}
return state;
}
// selectors
export function getRepositoryTypes(state: Object) {
if (state.repositoryTypes) {
return state.repositoryTypes;
}
return [];
}
export function isFetchRepositoryTypesPending(state: Object) {
return isPending(state, FETCH_REPOSITORY_TYPES);
}
export function getFetchRepositoryTypesFailure(state: Object) {
return getFailure(state, FETCH_REPOSITORY_TYPES);
}

View File

@@ -0,0 +1,198 @@
// @flow
import fetchMock from "fetch-mock";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import {
FETCH_REPOSITORY_TYPES,
FETCH_REPOSITORY_TYPES_FAILURE,
FETCH_REPOSITORY_TYPES_PENDING,
FETCH_REPOSITORY_TYPES_SUCCESS,
fetchRepositoryTypesIfNeeded,
fetchRepositoryTypesSuccess,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending,
shouldFetchRepositoryTypes
} from "./repositoryTypes";
import reducer from "./repositoryTypes";
const git = {
name: "git",
displayName: "Git",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/git"
}
}
};
const hg = {
name: "hg",
displayName: "Mercurial",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/hg"
}
}
};
const svn = {
name: "svn",
displayName: "Subversion",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/svn"
}
}
};
const collection = {
_embedded: {
repositoryTypes: [git, hg, svn]
},
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes"
}
}
};
describe("repository types caching", () => {
it("should fetch repository types, on empty state", () => {
expect(shouldFetchRepositoryTypes({})).toBe(true);
});
it("should fetch repository types, if the state contains an empty array", () => {
const state = {
repositoryTypes: []
};
expect(shouldFetchRepositoryTypes(state)).toBe(true);
});
it("should not fetch repository types, on pending state", () => {
const state = {
pending: {
[FETCH_REPOSITORY_TYPES]: true
}
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
it("should not fetch repository types, on failure state", () => {
const state = {
failure: {
[FETCH_REPOSITORY_TYPES]: new Error("no...")
}
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
it("should not fetch repository types, if they are already fetched", () => {
const state = {
repositoryTypes: [git, hg, svn]
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
});
describe("repository types fetch", () => {
const URL = "/api/v2/repositoryTypes";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch repository types", () => {
fetchMock.getOnce(URL, collection);
const expectedActions = [
{ type: FETCH_REPOSITORY_TYPES_PENDING },
{
type: FETCH_REPOSITORY_TYPES_SUCCESS,
payload: collection
}
];
const store = mockStore({});
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPOSITORY_TYPES_FAILURE on server error", () => {
fetchMock.getOnce(URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toBe(FETCH_REPOSITORY_TYPES_PENDING);
expect(actions[1].type).toBe(FETCH_REPOSITORY_TYPES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should dispatch not dispatch any action, if the repository types are already fetched", () => {
const store = mockStore({
repositoryTypes: [git, hg, svn]
});
store.dispatch(fetchRepositoryTypesIfNeeded());
expect(store.getActions().length).toBe(0);
});
});
describe("repository types reducer", () => {
it("should return unmodified state on unknown action", () => {
const state = [];
expect(reducer(state)).toBe(state);
});
it("should store the repository types on FETCH_REPOSITORY_TYPES_SUCCESS", () => {
const newState = reducer([], fetchRepositoryTypesSuccess(collection));
expect(newState).toEqual([git, hg, svn]);
});
});
describe("repository types selectors", () => {
const error = new Error("The end of the universe");
it("should return an emtpy array", () => {
expect(getRepositoryTypes({})).toEqual([]);
});
it("should return the repository types", () => {
const state = {
repositoryTypes: [git, hg, svn]
};
expect(getRepositoryTypes(state)).toEqual([git, hg, svn]);
});
it("should return true, when fetch repository types is pending", () => {
const state = {
pending: {
[FETCH_REPOSITORY_TYPES]: true
}
};
expect(isFetchRepositoryTypesPending(state)).toEqual(true);
});
it("should return false, when fetch repos is not pending", () => {
expect(isFetchRepositoryTypesPending({})).toEqual(false);
});
it("should return error when fetch repository types did fail", () => {
const state = {
failure: {
[FETCH_REPOSITORY_TYPES]: error
}
};
expect(getFetchRepositoryTypesFailure(state)).toEqual(error);
});
it("should return undefined when fetch repos did not fail", () => {
expect(getFetchRepositoryTypesFailure({})).toBe(undefined);
});
});

View File

@@ -0,0 +1,31 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox } from "@scm-manager/ui-components";
type Props = {
t: string => string,
disabled: boolean,
name: string,
checked: boolean,
onChange?: (value: boolean, name?: string) => void
};
class PermissionCheckbox extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<Checkbox
key={this.props.name}
name={this.props.name}
helpText={t("verbs.repository." + this.props.name + ".description")}
label={t("verbs.repository." + this.props.name + ".displayName")}
checked={this.props.checked}
onChange={this.props.onChange}
disabled={this.props.disabled}
/>
);
}
}
export default translate("plugins")(PermissionCheckbox);

View File

@@ -0,0 +1,55 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Select } from "@scm-manager/ui-components";
type Props = {
t: string => string,
availableRoles: string[],
handleRoleChange: string => void,
role: string,
label?: string,
helpText?: string,
loading?: boolean
};
class RoleSelector extends React.Component<Props> {
render() {
const {
availableRoles,
role,
handleRoleChange,
loading,
label,
helpText
} = this.props;
if (!availableRoles) return null;
const options = role
? this.createSelectOptions(availableRoles)
: ["", ...this.createSelectOptions(availableRoles)];
return (
<Select
onChange={handleRoleChange}
value={role ? role : ""}
options={options}
loading={loading}
label={label}
helpText={helpText}
/>
);
}
createSelectOptions(roles: string[]) {
return roles.map(role => {
return {
label: role,
value: role
};
});
}
}
export default translate("repos")(RoleSelector);

View File

@@ -0,0 +1,73 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { Permission } from "@scm-manager/ui-types";
import { confirmAlert } from "@scm-manager/ui-components";
type Props = {
permission: Permission,
namespace: string,
repoName: string,
confirmDialog?: boolean,
t: string => string,
deletePermission: (
permission: Permission,
namespace: string,
repoName: string
) => void,
loading: boolean
};
class DeletePermissionButton extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deletePermission = () => {
this.props.deletePermission(
this.props.permission,
this.props.namespace,
this.props.repoName
);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("permission.delete-permission-button.confirm-alert.title"),
message: t("permission.delete-permission-button.confirm-alert.message"),
buttons: [
{
label: t("permission.delete-permission-button.confirm-alert.submit"),
onClick: () => this.deletePermission()
},
{
label: t("permission.delete-permission-button.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.permission._links.delete;
};
render() {
const { confirmDialog } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deletePermission;
if (!this.isDeletable()) {
return null;
}
return (
<a className="level-item" onClick={action}>
<span className="icon is-small">
<i className="fas fa-trash" />
</span>
</a>
);
}
}
export default translate("repos")(DeletePermissionButton);

View File

@@ -0,0 +1,98 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../../../tests/enzyme";
import "../../../../tests/i18n";
import DeletePermissionButton from "./DeletePermissionButton";
import { confirmAlert } from "@scm-manager/ui-components";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
jest.mock("@scm-manager/ui-components", () => ({
confirmAlert: jest.fn(),
DeleteButton: require.requireActual("@scm-manager/ui-components").DeleteButton
}));
describe("DeletePermissionButton", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the delete link is missing", () => {
const permission = {
_links: {}
};
const navLink = shallow(
<DeletePermissionButton
permission={permission}
deletePermission={() => {}}
/>,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the delete icon", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
const deleteIcon = mount(
<DeletePermissionButton
permission={permission}
deletePermission={() => {}}
/>,
options.get()
);
expect(deleteIcon.html()).not.toBe("");
});
it("should open the confirm dialog on button click", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
const button = mount(
<DeletePermissionButton
permission={permission}
deletePermission={() => {}}
/>,
options.get()
);
button.find(".fa-trash").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete permission function with delete url", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
let calledUrl = null;
function capture(permission) {
calledUrl = permission._links.delete.href;
}
const button = mount(
<DeletePermissionButton
permission={permission}
confirmDialog={false}
deletePermission={capture}
/>,
options.get()
);
button.find(".fa-trash").simulate("click");
expect(calledUrl).toBe("/permission");
});
});

View File

@@ -0,0 +1,33 @@
// @flow
import {validation} from "@scm-manager/ui-components";
import type {PermissionCollection} from "@scm-manager/ui-types";
const isNameValid = validation.isNameValid;
export { isNameValid };
export const isPermissionValid = (
name: string,
groupPermission: boolean,
permissions: PermissionCollection
) => {
return (
isNameValid(name) &&
!currentPermissionIncludeName(name, groupPermission, permissions)
);
};
const currentPermissionIncludeName = (
name: string,
groupPermission: boolean,
permissions: PermissionCollection
) => {
for (let i = 0; i < permissions.length; i++) {
if (
permissions[i].name === name &&
permissions[i].groupPermission === groupPermission
)
return true;
}
return false;
};

View File

@@ -0,0 +1,70 @@
//@flow
import * as validator from "./permissionValidation";
describe("permission validation", () => {
it("should return true if permission is valid and does not exist", () => {
const permissions = [];
const name = "PermissionName";
const groupPermission = false;
expect(
validator.isPermissionValid(name, groupPermission, permissions)
).toBe(true);
});
it("should return true if permission is valid and does not exists with same group permission", () => {
const permissions = [
{
name: "PermissionName",
groupPermission: true,
type: "READ",
_links: {},
verbs: []
}
];
const name = "PermissionName";
const groupPermission = false;
expect(
validator.isPermissionValid(name, groupPermission, permissions)
).toBe(true);
});
it("should return false if permission is valid but exists", () => {
const permissions = [
{
name: "PermissionName",
groupPermission: false,
type: "READ",
_links: {},
verbs: []
}
];
const name = "PermissionName";
const groupPermission = false;
expect(
validator.isPermissionValid(name, groupPermission, permissions)
).toBe(false);
});
it("should return false if permission does not exist but is invalid", () => {
const permissions = [];
const name = "@PermissionName";
const groupPermission = false;
expect(
validator.isPermissionValid(name, groupPermission, permissions)
).toBe(false);
});
it("should return false if permission is not valid and does not exist", () => {
const permissions = [];
const name = "@PermissionName";
const groupPermission = false;
expect(
validator.isPermissionValid(name, groupPermission, permissions)
).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
// @flow
import React from "react";
import {
ButtonGroup,
Button,
SubmitButton,
Modal
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import PermissionCheckbox from "../components/PermissionCheckbox";
type Props = {
readOnly: boolean,
availableVerbs: string[],
selectedVerbs: string[],
onSubmit: (string[]) => void,
onClose: () => void,
// context props
t: string => string
};
type State = {
verbs: any
};
class AdvancedPermissionsDialog extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
const verbs = {};
props.availableVerbs.forEach(
verb =>
(verbs[verb] = props.selectedVerbs
? props.selectedVerbs.includes(verb)
: false)
);
this.state = { verbs };
}
render() {
const { t, onClose, readOnly } = this.props;
const { verbs } = this.state;
const verbSelectBoxes = Object.entries(verbs).map(e => (
<PermissionCheckbox
key={e[0]}
disabled={readOnly}
name={e[0]}
checked={e[1]}
onChange={this.handleChange}
/>
));
const submitButton = !readOnly ? (
<SubmitButton label={t("permission.advanced.dialog.submit")} />
) : null;
const body = <>{verbSelectBoxes}</>;
const footer = (
<form onSubmit={this.onSubmit}>
<ButtonGroup>
{submitButton}
<Button
label={t("permission.advanced.dialog.abort")}
action={onClose}
/>
</ButtonGroup>
</form>
);
return (
<Modal
title={t("permission.advanced.dialog.title")}
closeFunction={() => onClose()}
body={body}
footer={footer}
active={true}
/>
);
}
handleChange = (value: boolean, name: string) => {
const { verbs } = this.state;
const newVerbs = { ...verbs, [name]: value };
this.setState({ verbs: newVerbs });
};
onSubmit = () => {
this.props.onSubmit(
Object.entries(this.state.verbs)
.filter(e => e[1])
.map(e => e[0])
);
};
}
export default translate("repos")(AdvancedPermissionsDialog);

View File

@@ -0,0 +1,235 @@
// @flow
import React from "react";
import {translate} from "react-i18next";
import type {PermissionCollection, PermissionCreateEntry, RepositoryRole, SelectValue} from "@scm-manager/ui-types";
import {
Button,
GroupAutocomplete,
LabelWithHelpIcon,
Radio,
SubmitButton,
Subtitle,
UserAutocomplete
} from "@scm-manager/ui-components";
import * as validator from "../components/permissionValidation";
import RoleSelector from "../components/RoleSelector";
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
import {findVerbsForRole} from "../modules/permissions";
type Props = {
availableRoles: RepositoryRole[],
availableVerbs: string[],
createPermission: (permission: PermissionCreateEntry) => void,
loading: boolean,
currentPermissions: PermissionCollection,
groupAutocompleteLink: string,
userAutocompleteLink: string,
// Context props
t: string => string
};
type State = {
name: string,
role?: string,
verbs?: string[],
groupPermission: boolean,
valid: boolean,
value?: SelectValue,
showAdvancedDialog: boolean
};
class CreatePermissionForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
name: "",
role: props.availableRoles[0].name,
verbs: undefined,
groupPermission: false,
valid: true,
value: undefined,
showAdvancedDialog: false
};
}
permissionScopeChanged = event => {
const groupPermission = event.target.value === "GROUP_PERMISSION";
this.setState({
value: undefined,
name: "",
groupPermission: groupPermission,
valid: false
});
};
renderAutocompletionField = () => {
const group = this.state.groupPermission;
if (group) {
return (
<GroupAutocomplete
autocompleteLink={this.props.groupAutocompleteLink}
valueSelected={this.selectName}
value={this.state.value ? this.state.value : ""}
/>
);
}
return (
<UserAutocomplete
autocompleteLink={this.props.userAutocompleteLink}
valueSelected={this.selectName}
value={this.state.value ? this.state.value : ""}
/>
);
};
selectName = (value: SelectValue) => {
this.setState({
value,
name: value.value.id,
valid: validator.isPermissionValid(
value.value.id,
this.state.groupPermission,
this.props.currentPermissions
)
});
};
render() {
const { t, availableRoles, availableVerbs, loading } = this.props;
const { role, verbs, showAdvancedDialog } = this.state;
const availableRoleNames = availableRoles.map(r => r.name);
const selectedVerbs = role ? findVerbsForRole(availableRoles, role) : verbs;
const advancedDialog = showAdvancedDialog ? (
<AdvancedPermissionsDialog
availableVerbs={availableVerbs}
selectedVerbs={selectedVerbs}
onClose={this.toggleAdvancedPermissionsDialog}
onSubmit={this.submitAdvancedPermissionsDialog}
/>
) : null;
return (
<>
<hr />
<Subtitle
subtitle={t("permission.add-permission.add-permission-heading")}
/>
{advancedDialog}
<form onSubmit={this.submit}>
<div className="field is-grouped">
<div className="control">
<Radio
name="permission_scope"
value="USER_PERMISSION"
checked={!this.state.groupPermission}
label={t("permission.user-permission")}
onChange={this.permissionScopeChanged}
/>
<Radio
name="permission_scope"
value="GROUP_PERMISSION"
checked={this.state.groupPermission}
label={t("permission.group-permission")}
onChange={this.permissionScopeChanged}
/>
</div>
</div>
<div className="columns">
<div className="column is-three-fifths">
{this.renderAutocompletionField()}
</div>
<div className="column is-two-fifths">
<div className="columns">
<div className="column is-narrow">
<RoleSelector
availableRoles={availableRoleNames}
label={t("permission.role")}
helpText={t("permission.help.roleHelpText")}
handleRoleChange={this.handleRoleChange}
role={role}
/>
</div>
<div className="column">
<LabelWithHelpIcon
label={t("permission.permissions")}
helpText={t("permission.help.permissionsHelpText")}
/>
<Button
label={t("permission.advanced-button.label")}
action={this.toggleAdvancedPermissionsDialog}
/>
</div>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<SubmitButton
label={t("permission.add-permission.submit-button")}
loading={loading}
disabled={!this.state.valid || this.state.name === ""}
/>
</div>
</div>
</form>
</>
);
}
toggleAdvancedPermissionsDialog = () => {
this.setState(prevState => ({
showAdvancedDialog: !prevState.showAdvancedDialog
}));
};
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
this.setState({
showAdvancedDialog: false,
role: undefined,
verbs: newVerbs
});
};
submit = e => {
this.props.createPermission({
name: this.state.name,
role: this.state.role,
verbs: this.state.verbs,
groupPermission: this.state.groupPermission
});
this.removeState();
e.preventDefault();
};
removeState = () => {
this.setState({
name: "",
role: this.props.availableRoles[0].name,
verbs: undefined,
valid: true,
value: undefined
});
};
handleRoleChange = (role: string) => {
const selectedRole = this.findAvailableRole(role);
if (!selectedRole) {
return;
}
this.setState({
role: selectedRole.name,
verbs: []
});
};
findAvailableRole = (roleName: string) => {
return this.props.availableRoles.find(role => role.name === roleName);
};
}
export default translate("repos")(CreatePermissionForm);

View File

@@ -0,0 +1,288 @@
//@flow
import React from "react";
import {connect} from "react-redux";
import {translate} from "react-i18next";
import {
createPermission,
createPermissionReset,
deletePermissionReset,
fetchAvailablePermissionsIfNeeded,
fetchPermissions,
getAvailablePermissions,
getAvailableRepositoryRoles,
getAvailableRepositoryVerbs,
getCreatePermissionFailure,
getDeletePermissionsFailure,
getFetchAvailablePermissionsFailure,
getFetchPermissionsFailure,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
isCreatePermissionPending,
isFetchAvailablePermissionsPending,
isFetchPermissionsPending,
modifyPermissionReset
} from "../modules/permissions";
import {ErrorPage, LabelWithHelpIcon, Loading, Subtitle} from "@scm-manager/ui-components";
import type {Permission, PermissionCollection, PermissionCreateEntry, RepositoryRole} from "@scm-manager/ui-types";
import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "./CreatePermissionForm";
import type {History} from "history";
import {getPermissionsLink} from "../../modules/repos";
import {
getGroupAutoCompleteLink,
getRepositoryRolesLink,
getRepositoryVerbsLink,
getUserAutoCompleteLink
} from "../../../modules/indexResource";
type Props = {
availablePermissions: boolean,
availableRepositoryRoles: RepositoryRole[],
availableVerbs: string[],
namespace: string,
repoName: string,
loading: boolean,
error: Error,
permissions: PermissionCollection,
hasPermissionToCreate: boolean,
loadingCreatePermission: boolean,
repositoryRolesLink: string,
repositoryVerbsLink: string,
permissionsLink: string,
groupAutocompleteLink: string,
userAutocompleteLink: string,
//dispatch functions
fetchAvailablePermissionsIfNeeded: (
repositoryRolesLink: string,
repositoryVerbsLink: string
) => void,
fetchPermissions: (link: string, namespace: string, repoName: string) => void,
createPermission: (
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
) => void,
createPermissionReset: (string, string) => void,
modifyPermissionReset: (string, string) => void,
deletePermissionReset: (string, string) => void,
// context props
t: string => string,
match: any,
history: History
};
class Permissions extends React.Component<Props> {
componentDidMount() {
const {
fetchAvailablePermissionsIfNeeded,
fetchPermissions,
namespace,
repoName,
modifyPermissionReset,
createPermissionReset,
deletePermissionReset,
permissionsLink,
repositoryRolesLink,
repositoryVerbsLink
} = this.props;
createPermissionReset(namespace, repoName);
modifyPermissionReset(namespace, repoName);
deletePermissionReset(namespace, repoName);
fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink);
fetchPermissions(permissionsLink, namespace, repoName);
}
createPermission = (permission: Permission) => {
this.props.createPermission(
this.props.permissionsLink,
permission,
this.props.namespace,
this.props.repoName
);
};
render() {
const {
availablePermissions,
availableRepositoryRoles,
availableVerbs,
loading,
error,
permissions,
t,
namespace,
repoName,
loadingCreatePermission,
hasPermissionToCreate,
userAutocompleteLink,
groupAutocompleteLink
} = this.props;
if (error) {
return (
<ErrorPage
title={t("permission.error-title")}
subtitle={t("permission.error-subtitle")}
error={error}
/>
);
}
if (loading || !permissions || !availablePermissions) {
return <Loading />;
}
const createPermissionForm = hasPermissionToCreate ? (
<CreatePermissionForm
availableRoles={availableRepositoryRoles}
availableVerbs={availableVerbs}
createPermission={permission => this.createPermission(permission)}
loading={loadingCreatePermission}
currentPermissions={permissions}
userAutocompleteLink={userAutocompleteLink}
groupAutocompleteLink={groupAutocompleteLink}
/>
) : null;
return (
<div>
<Subtitle subtitle={t("permission.title")} />
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<LabelWithHelpIcon
label={t("permission.name")}
helpText={t("permission.help.nameHelpText")}
/>
</th>
<th>
<LabelWithHelpIcon
label={t("permission.role")}
helpText={t("permission.help.roleHelpText")}
/>
</th>
<th>
<LabelWithHelpIcon
label={t("permission.permissions")}
helpText={t("permission.help.permissionsHelpText")}
/>
</th>
<th />
</tr>
</thead>
<tbody>
{permissions.map(permission => {
return (
<SinglePermission
availableRepositoryRoles={availableRepositoryRoles}
availableRepositoryVerbs={availableVerbs}
key={permission.name + permission.groupPermission.toString()}
namespace={namespace}
repoName={repoName}
permission={permission}
/>
);
})}
</tbody>
</table>
{createPermissionForm}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const namespace = ownProps.namespace;
const repoName = ownProps.repoName;
const error =
getFetchPermissionsFailure(state, namespace, repoName) ||
getCreatePermissionFailure(state, namespace, repoName) ||
getDeletePermissionsFailure(state, namespace, repoName) ||
getModifyPermissionsFailure(state, namespace, repoName) ||
getFetchAvailablePermissionsFailure(state);
const loading =
isFetchPermissionsPending(state, namespace, repoName) ||
isFetchAvailablePermissionsPending(state);
const permissions = getPermissionsOfRepo(state, namespace, repoName);
const loadingCreatePermission = isCreatePermissionPending(
state,
namespace,
repoName
);
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
const repositoryRolesLink = getRepositoryRolesLink(state);
const repositoryVerbsLink = getRepositoryVerbsLink(state);
const permissionsLink = getPermissionsLink(state, namespace, repoName);
const groupAutocompleteLink = getGroupAutoCompleteLink(state);
const userAutocompleteLink = getUserAutoCompleteLink(state);
const availablePermissions = getAvailablePermissions(state);
const availableRepositoryRoles = getAvailableRepositoryRoles(state);
const availableVerbs = getAvailableRepositoryVerbs(state);
return {
availablePermissions,
availableRepositoryRoles,
availableVerbs,
namespace,
repoName,
repositoryRolesLink,
repositoryVerbsLink,
error,
loading,
permissions,
hasPermissionToCreate,
loadingCreatePermission,
permissionsLink,
groupAutocompleteLink,
userAutocompleteLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchPermissions: (link: string, namespace: string, repoName: string) => {
dispatch(fetchPermissions(link, namespace, repoName));
},
fetchAvailablePermissionsIfNeeded: (
repositoryRolesLink: string,
repositoryVerbsLink: string
) => {
dispatch(
fetchAvailablePermissionsIfNeeded(
repositoryRolesLink,
repositoryVerbsLink
)
);
},
createPermission: (
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
) => {
dispatch(
createPermission(link, permission, namespace, repoName, callback)
);
},
createPermissionReset: (namespace: string, repoName: string) => {
dispatch(createPermissionReset(namespace, repoName));
},
modifyPermissionReset: (namespace: string, repoName: string) => {
dispatch(modifyPermissionReset(namespace, repoName));
},
deletePermissionReset: (namespace: string, repoName: string) => {
dispatch(deletePermissionReset(namespace, repoName));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(Permissions));

View File

@@ -0,0 +1,277 @@
// @flow
import React from "react";
import type { RepositoryRole, Permission } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import {
modifyPermission,
isModifyPermissionPending,
deletePermission,
isDeletePermissionPending,
findVerbsForRole
} from "../modules/permissions";
import { connect } from "react-redux";
import type { History } from "history";
import { Button, Icon } from "@scm-manager/ui-components";
import DeletePermissionButton from "../components/buttons/DeletePermissionButton";
import RoleSelector from "../components/RoleSelector";
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
import classNames from "classnames";
import injectSheet from "react-jss";
type Props = {
availableRepositoryRoles: RepositoryRole[],
availableRepositoryVerbs: string[],
submitForm: Permission => void,
modifyPermission: (
permission: Permission,
namespace: string,
name: string
) => void,
permission: Permission,
t: string => string,
namespace: string,
repoName: string,
match: any,
history: History,
loading: boolean,
deletePermission: (
permission: Permission,
namespace: string,
name: string
) => void,
deleteLoading: boolean,
classes: any
};
type State = {
permission: Permission,
showAdvancedDialog: boolean
};
const styles = {
centerMiddle: {
display: "table-cell",
verticalAlign: "middle !important"
},
columnWidth: {
width: "100%"
}
};
class SinglePermission extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
const defaultPermission = props.availableRepositoryRoles
? props.availableRepositoryRoles[0]
: {};
this.state = {
permission: {
name: "",
role: undefined,
verbs: defaultPermission.verbs,
groupPermission: false,
_links: {}
},
showAdvancedDialog: false
};
}
componentDidMount() {
const { permission } = this.props;
if (permission) {
this.setState({
permission: {
name: permission.name,
role: permission.role,
verbs: permission.verbs,
groupPermission: permission.groupPermission,
_links: permission._links
}
});
}
}
deletePermission = () => {
this.props.deletePermission(
this.props.permission,
this.props.namespace,
this.props.repoName
);
};
render() {
const { permission, showAdvancedDialog } = this.state;
const {
t,
availableRepositoryRoles,
availableRepositoryVerbs,
loading,
namespace,
repoName,
classes
} = this.props;
const availableRoleNames =
!!availableRepositoryRoles && availableRepositoryRoles.map(r => r.name);
const readOnly = !this.mayChangePermissions();
const roleSelector = readOnly ? (
<td>{permission.role ? permission.role : t("permission.custom")}</td>
) : (
<td>
<RoleSelector
handleRoleChange={this.handleRoleChange}
availableRoles={availableRoleNames}
role={permission.role}
loading={loading}
/>
</td>
);
const selectedVerbs = permission.role
? findVerbsForRole(availableRepositoryRoles, permission.role)
: permission.verbs;
const advancedDialog = showAdvancedDialog ? (
<AdvancedPermissionsDialog
readOnly={readOnly}
availableVerbs={availableRepositoryVerbs}
selectedVerbs={selectedVerbs}
onClose={this.closeAdvancedPermissionsDialog}
onSubmit={this.submitAdvancedPermissionsDialog}
/>
) : null;
const iconType =
permission && permission.groupPermission ? (
<Icon title={t("permission.group")} name="user-friends" />
) : (
<Icon title={t("permission.user")} name="user" />
);
return (
<tr className={classes.columnWidth}>
<td className={classes.centerMiddle}>
{iconType} {permission.name}
</td>
{roleSelector}
<td className={classes.centerMiddle}>
<Button
label={t("permission.advanced-button.label")}
action={this.handleDetailedPermissionsPressed}
/>
</td>
<td className={classNames("is-darker", classes.centerMiddle)}>
<DeletePermissionButton
permission={permission}
namespace={namespace}
repoName={repoName}
deletePermission={this.deletePermission}
loading={this.props.deleteLoading}
/>
{advancedDialog}
</td>
</tr>
);
}
mayChangePermissions = () => {
return this.props.permission._links && this.props.permission._links.update;
};
handleDetailedPermissionsPressed = () => {
this.setState({ showAdvancedDialog: true });
};
closeAdvancedPermissionsDialog = () => {
this.setState({ showAdvancedDialog: false });
};
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
const { permission } = this.state;
this.setState(
{
showAdvancedDialog: false,
permission: { ...permission, role: undefined, verbs: newVerbs }
},
() => this.modifyPermissionVerbs(newVerbs)
);
};
handleRoleChange = (role: string) => {
const { permission } = this.state;
this.setState(
{
permission: { ...permission, role: role, verbs: undefined }
},
() => this.modifyPermissionRole(role)
);
};
findAvailableRole = (roleName: string) => {
const { availableRepositoryRoles } = this.props;
return availableRepositoryRoles.find(role => role.name === roleName);
};
modifyPermissionRole = (role: string) => {
let permission = this.state.permission;
permission.role = role;
this.props.modifyPermission(
permission,
this.props.namespace,
this.props.repoName
);
};
modifyPermissionVerbs = (verbs: string[]) => {
let permission = this.state.permission;
permission.verbs = verbs;
this.props.modifyPermission(
permission,
this.props.namespace,
this.props.repoName
);
};
}
const mapStateToProps = (state, ownProps) => {
const permission = ownProps.permission;
const loading = isModifyPermissionPending(
state,
ownProps.namespace,
ownProps.repoName,
permission
);
const deleteLoading = isDeletePermissionPending(
state,
ownProps.namespace,
ownProps.repoName,
permission
);
return { loading, deleteLoading };
};
const mapDispatchToProps = dispatch => {
return {
modifyPermission: (
permission: Permission,
namespace: string,
repoName: string
) => {
dispatch(modifyPermission(permission, namespace, repoName));
},
deletePermission: (
permission: Permission,
namespace: string,
repoName: string
) => {
dispatch(deletePermission(permission, namespace, repoName));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(injectSheet(styles)(SinglePermission)));

View File

@@ -0,0 +1,755 @@
// @flow
import type { Action } from "@scm-manager/ui-components";
import { apiClient } from "@scm-manager/ui-components";
import * as types from "../../../modules/types";
import type {
RepositoryRole,
Permission,
PermissionCollection,
PermissionCreateEntry
} from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import { Dispatch } from "redux";
export const FETCH_AVAILABLE = "scm/permissions/FETCH_AVAILABLE";
export const FETCH_AVAILABLE_PENDING = `${FETCH_AVAILABLE}_${
types.PENDING_SUFFIX
}`;
export const FETCH_AVAILABLE_SUCCESS = `${FETCH_AVAILABLE}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_AVAILABLE_FAILURE = `${FETCH_AVAILABLE}_${
types.FAILURE_SUFFIX
}`;
export const FETCH_PERMISSIONS = "scm/permissions/FETCH_PERMISSIONS";
export const FETCH_PERMISSIONS_PENDING = `${FETCH_PERMISSIONS}_${
types.PENDING_SUFFIX
}`;
export const FETCH_PERMISSIONS_SUCCESS = `${FETCH_PERMISSIONS}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_PERMISSIONS_FAILURE = `${FETCH_PERMISSIONS}_${
types.FAILURE_SUFFIX
}`;
export const MODIFY_PERMISSION = "scm/permissions/MODFIY_PERMISSION";
export const MODIFY_PERMISSION_PENDING = `${MODIFY_PERMISSION}_${
types.PENDING_SUFFIX
}`;
export const MODIFY_PERMISSION_SUCCESS = `${MODIFY_PERMISSION}_${
types.SUCCESS_SUFFIX
}`;
export const MODIFY_PERMISSION_FAILURE = `${MODIFY_PERMISSION}_${
types.FAILURE_SUFFIX
}`;
export const MODIFY_PERMISSION_RESET = `${MODIFY_PERMISSION}_${
types.RESET_SUFFIX
}`;
export const CREATE_PERMISSION = "scm/permissions/CREATE_PERMISSION";
export const CREATE_PERMISSION_PENDING = `${CREATE_PERMISSION}_${
types.PENDING_SUFFIX
}`;
export const CREATE_PERMISSION_SUCCESS = `${CREATE_PERMISSION}_${
types.SUCCESS_SUFFIX
}`;
export const CREATE_PERMISSION_FAILURE = `${CREATE_PERMISSION}_${
types.FAILURE_SUFFIX
}`;
export const CREATE_PERMISSION_RESET = `${CREATE_PERMISSION}_${
types.RESET_SUFFIX
}`;
export const DELETE_PERMISSION = "scm/permissions/DELETE_PERMISSION";
export const DELETE_PERMISSION_PENDING = `${DELETE_PERMISSION}_${
types.PENDING_SUFFIX
}`;
export const DELETE_PERMISSION_SUCCESS = `${DELETE_PERMISSION}_${
types.SUCCESS_SUFFIX
}`;
export const DELETE_PERMISSION_FAILURE = `${DELETE_PERMISSION}_${
types.FAILURE_SUFFIX
}`;
export const DELETE_PERMISSION_RESET = `${DELETE_PERMISSION}_${
types.RESET_SUFFIX
}`;
const CONTENT_TYPE = "application/vnd.scmm-repositoryPermission+json";
// fetch available permissions
export function fetchAvailablePermissionsIfNeeded(
repositoryRolesLink: string,
repositoryVerbsLink: string
) {
return function(dispatch: any, getState: () => Object) {
if (shouldFetchAvailablePermissions(getState())) {
return fetchAvailablePermissions(
dispatch,
getState,
repositoryRolesLink,
repositoryVerbsLink
);
}
};
}
export function fetchAvailablePermissions(
dispatch: any,
getState: () => Object,
repositoryRolesLink: string,
repositoryVerbsLink: string
) {
dispatch(fetchAvailablePending());
return apiClient
.get(repositoryRolesLink)
.then(repositoryRoles => repositoryRoles.json())
.then(repositoryRoles => repositoryRoles._embedded.repositoryRoles)
.then(repositoryRoles => {
return apiClient
.get(repositoryVerbsLink)
.then(repositoryVerbs => repositoryVerbs.json())
.then(repositoryVerbs => repositoryVerbs.verbs)
.then(repositoryVerbs => {
return {
repositoryVerbs,
repositoryRoles
};
});
})
.then(available => {
dispatch(fetchAvailableSuccess(available));
})
.catch(err => {
dispatch(fetchAvailableFailure(err));
});
}
export function shouldFetchAvailablePermissions(state: Object) {
if (
isFetchAvailablePermissionsPending(state) ||
getFetchAvailablePermissionsFailure(state)
) {
return false;
}
return !state.available;
}
export function fetchAvailablePending(): Action {
return {
type: FETCH_AVAILABLE_PENDING,
payload: {},
itemId: "available"
};
}
export function fetchAvailableSuccess(
available: [RepositoryRole[], string[]]
): Action {
return {
type: FETCH_AVAILABLE_SUCCESS,
payload: available,
itemId: "available"
};
}
export function fetchAvailableFailure(error: Error): Action {
return {
type: FETCH_AVAILABLE_FAILURE,
payload: {
error
},
itemId: "available"
};
}
// fetch permissions
export function fetchPermissions(
link: string,
namespace: string,
repoName: string
) {
return function(dispatch: any) {
dispatch(fetchPermissionsPending(namespace, repoName));
return apiClient
.get(link)
.then(response => response.json())
.then(permissions => {
dispatch(fetchPermissionsSuccess(permissions, namespace, repoName));
})
.catch(err => {
dispatch(fetchPermissionsFailure(namespace, repoName, err));
});
};
}
export function fetchPermissionsPending(
namespace: string,
repoName: string
): Action {
return {
type: FETCH_PERMISSIONS_PENDING,
payload: {
namespace,
repoName
},
itemId: namespace + "/" + repoName
};
}
export function fetchPermissionsSuccess(
permissions: any,
namespace: string,
repoName: string
): Action {
return {
type: FETCH_PERMISSIONS_SUCCESS,
payload: permissions,
itemId: namespace + "/" + repoName
};
}
export function fetchPermissionsFailure(
namespace: string,
repoName: string,
error: Error
): Action {
return {
type: FETCH_PERMISSIONS_FAILURE,
payload: {
namespace,
repoName,
error
},
itemId: namespace + "/" + repoName
};
}
// modify permission
export function modifyPermission(
permission: Permission,
namespace: string,
repoName: string,
callback?: () => void
) {
return function(dispatch: any) {
dispatch(modifyPermissionPending(permission, namespace, repoName));
return apiClient
.put(permission._links.update.href, permission, CONTENT_TYPE)
.then(() => {
dispatch(modifyPermissionSuccess(permission, namespace, repoName));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyPermissionFailure(permission, err, namespace, repoName));
});
};
}
export function modifyPermissionPending(
permission: Permission,
namespace: string,
repoName: string
): Action {
return {
type: MODIFY_PERMISSION_PENDING,
payload: permission,
itemId: createItemId(permission, namespace, repoName)
};
}
export function modifyPermissionSuccess(
permission: Permission,
namespace: string,
repoName: string
): Action {
return {
type: MODIFY_PERMISSION_SUCCESS,
payload: {
permission,
position: namespace + "/" + repoName
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function modifyPermissionFailure(
permission: Permission,
error: Error,
namespace: string,
repoName: string
): Action {
return {
type: MODIFY_PERMISSION_FAILURE,
payload: { error, permission },
itemId: createItemId(permission, namespace, repoName)
};
}
function newPermissions(
oldPermissions: PermissionCollection,
newPermission: Permission
) {
for (let i = 0; i < oldPermissions.length; i++) {
if (oldPermissions[i].name === newPermission.name) {
oldPermissions.splice(i, 1, newPermission);
return oldPermissions;
}
}
}
export function modifyPermissionReset(namespace: string, repoName: string) {
return {
type: MODIFY_PERMISSION_RESET,
payload: {
namespace,
repoName
},
itemId: namespace + "/" + repoName
};
}
// create permission
export function createPermission(
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
) {
return function(dispatch: Dispatch) {
dispatch(createPermissionPending(permission, namespace, repoName));
return apiClient
.post(link, permission, CONTENT_TYPE)
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location);
})
.then(response => response.json())
.then(createdPermission => {
dispatch(
createPermissionSuccess(createdPermission, namespace, repoName)
);
if (callback) {
callback();
}
})
.catch(err =>
dispatch(createPermissionFailure(err, namespace, repoName))
);
};
}
export function createPermissionPending(
permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {
return {
type: CREATE_PERMISSION_PENDING,
payload: permission,
itemId: namespace + "/" + repoName
};
}
export function createPermissionSuccess(
permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {
return {
type: CREATE_PERMISSION_SUCCESS,
payload: {
permission,
position: namespace + "/" + repoName
},
itemId: namespace + "/" + repoName
};
}
export function createPermissionFailure(
error: Error,
namespace: string,
repoName: string
): Action {
return {
type: CREATE_PERMISSION_FAILURE,
payload: error,
itemId: namespace + "/" + repoName
};
}
export function createPermissionReset(namespace: string, repoName: string) {
return {
type: CREATE_PERMISSION_RESET,
itemId: namespace + "/" + repoName
};
}
// delete permission
export function deletePermission(
permission: Permission,
namespace: string,
repoName: string,
callback?: () => void
) {
return function(dispatch: any) {
dispatch(deletePermissionPending(permission, namespace, repoName));
return apiClient
.delete(permission._links.delete.href)
.then(() => {
dispatch(deletePermissionSuccess(permission, namespace, repoName));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(deletePermissionFailure(permission, namespace, repoName, err));
});
};
}
export function deletePermissionPending(
permission: Permission,
namespace: string,
repoName: string
): Action {
return {
type: DELETE_PERMISSION_PENDING,
payload: permission,
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionSuccess(
permission: Permission,
namespace: string,
repoName: string
): Action {
return {
type: DELETE_PERMISSION_SUCCESS,
payload: {
permission,
position: namespace + "/" + repoName
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionFailure(
permission: Permission,
namespace: string,
repoName: string,
error: Error
): Action {
return {
type: DELETE_PERMISSION_FAILURE,
payload: {
error,
permission
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionReset(namespace: string, repoName: string) {
return {
type: DELETE_PERMISSION_RESET,
payload: {
namespace,
repoName
},
itemId: namespace + "/" + repoName
};
}
function deletePermissionFromState(
oldPermissions: PermissionCollection,
permission: Permission
) {
let newPermission = [];
for (let i = 0; i < oldPermissions.length; i++) {
if (
oldPermissions[i].name !== permission.name ||
oldPermissions[i].groupPermission !== permission.groupPermission
) {
newPermission.push(oldPermissions[i]);
}
}
return newPermission;
}
function createItemId(
permission: Permission,
namespace: string,
repoName: string
) {
let groupPermission = permission.groupPermission ? "@" : "";
return namespace + "/" + repoName + "/" + groupPermission + permission.name;
}
// reducer
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_AVAILABLE_SUCCESS:
return {
...state,
available: action.payload
};
case FETCH_PERMISSIONS_SUCCESS:
return {
...state,
[action.itemId]: {
entries: action.payload._embedded.permissions,
createPermission: !!action.payload._links.create
}
};
case MODIFY_PERMISSION_SUCCESS:
const positionOfPermission = action.payload.position;
const newPermission = newPermissions(
state[action.payload.position].entries,
action.payload.permission
);
return {
...state,
[positionOfPermission]: {
...state[positionOfPermission],
entries: newPermission
}
};
case CREATE_PERMISSION_SUCCESS:
// return state;
const position = action.payload.position;
const permissions = state[action.payload.position].entries;
permissions.push(action.payload.permission);
return {
...state,
[position]: {
...state[position],
entries: permissions
}
};
case DELETE_PERMISSION_SUCCESS:
const permissionPosition = action.payload.position;
const new_Permissions = deletePermissionFromState(
state[action.payload.position].entries,
action.payload.permission
);
return {
...state,
[permissionPosition]: {
...state[permissionPosition],
entries: new_Permissions
}
};
default:
return state;
}
}
// selectors
export function getAvailablePermissions(state: Object) {
if (state.permissions) {
return state.permissions.available;
}
}
export function getAvailableRepositoryRoles(state: Object) {
return available(state).repositoryRoles;
}
export function getAvailableRepositoryVerbs(state: Object) {
return available(state).repositoryVerbs;
}
function available(state: Object) {
if (state.permissions && state.permissions.available) {
return state.permissions.available;
}
return {};
}
export function getPermissionsOfRepo(
state: Object,
namespace: string,
repoName: string
) {
if (state.permissions && state.permissions[namespace + "/" + repoName]) {
return state.permissions[namespace + "/" + repoName].entries;
}
}
export function isFetchAvailablePermissionsPending(state: Object) {
return isPending(state, FETCH_AVAILABLE, "available");
}
export function isFetchPermissionsPending(
state: Object,
namespace: string,
repoName: string
) {
return isPending(state, FETCH_PERMISSIONS, namespace + "/" + repoName);
}
export function getFetchAvailablePermissionsFailure(state: Object) {
return getFailure(state, FETCH_AVAILABLE, "available");
}
export function getFetchPermissionsFailure(
state: Object,
namespace: string,
repoName: string
) {
return getFailure(state, FETCH_PERMISSIONS, namespace + "/" + repoName);
}
export function isModifyPermissionPending(
state: Object,
namespace: string,
repoName: string,
permission: Permission
) {
return isPending(
state,
MODIFY_PERMISSION,
createItemId(permission, namespace, repoName)
);
}
export function getModifyPermissionFailure(
state: Object,
namespace: string,
repoName: string,
permission: Permission
) {
return getFailure(
state,
MODIFY_PERMISSION,
createItemId(permission, namespace, repoName)
);
}
export function hasCreatePermission(
state: Object,
namespace: string,
repoName: string
) {
if (state.permissions && state.permissions[namespace + "/" + repoName])
return state.permissions[namespace + "/" + repoName].createPermission;
else return null;
}
export function isCreatePermissionPending(
state: Object,
namespace: string,
repoName: string
) {
return isPending(state, CREATE_PERMISSION, namespace + "/" + repoName);
}
export function getCreatePermissionFailure(
state: Object,
namespace: string,
repoName: string
) {
return getFailure(state, CREATE_PERMISSION, namespace + "/" + repoName);
}
export function isDeletePermissionPending(
state: Object,
namespace: string,
repoName: string,
permission: Permission
) {
return isPending(
state,
DELETE_PERMISSION,
createItemId(permission, namespace, repoName)
);
}
export function getDeletePermissionFailure(
state: Object,
namespace: string,
repoName: string,
permission: Permission
) {
return getFailure(
state,
DELETE_PERMISSION,
createItemId(permission, namespace, repoName)
);
}
export function getDeletePermissionsFailure(
state: Object,
namespace: string,
repoName: string
) {
const permissions =
state.permissions && state.permissions[namespace + "/" + repoName]
? state.permissions[namespace + "/" + repoName].entries
: null;
if (permissions == null) return undefined;
for (let i = 0; i < permissions.length; i++) {
if (
getDeletePermissionFailure(state, namespace, repoName, permissions[i])
) {
return getFailure(
state,
DELETE_PERMISSION,
createItemId(permissions[i], namespace, repoName)
);
}
}
return null;
}
export function getModifyPermissionsFailure(
state: Object,
namespace: string,
repoName: string
) {
const permissions =
state.permissions && state.permissions[namespace + "/" + repoName]
? state.permissions[namespace + "/" + repoName].entries
: null;
if (permissions == null) return undefined;
for (let i = 0; i < permissions.length; i++) {
if (
getModifyPermissionFailure(state, namespace, repoName, permissions[i])
) {
return getFailure(
state,
MODIFY_PERMISSION,
createItemId(permissions[i], namespace, repoName)
);
}
}
return null;
}
export function findVerbsForRole(
availableRepositoryRoles: RepositoryRole[],
roleName: string
) {
const matchingRole = availableRepositoryRoles.find(
role => roleName === role.name
);
if (matchingRole) {
return matchingRole.verbs;
} else {
return [];
}
}

View File

@@ -0,0 +1,783 @@
// @flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
CREATE_PERMISSION,
CREATE_PERMISSION_FAILURE,
CREATE_PERMISSION_PENDING,
CREATE_PERMISSION_SUCCESS,
createPermission,
createPermissionSuccess,
DELETE_PERMISSION,
DELETE_PERMISSION_FAILURE,
DELETE_PERMISSION_PENDING,
DELETE_PERMISSION_SUCCESS,
deletePermission,
deletePermissionSuccess,
FETCH_PERMISSIONS,
FETCH_PERMISSIONS_FAILURE,
FETCH_PERMISSIONS_PENDING,
FETCH_PERMISSIONS_SUCCESS,
fetchPermissions,
fetchPermissionsSuccess,
getCreatePermissionFailure,
getDeletePermissionFailure,
getDeletePermissionsFailure,
getFetchPermissionsFailure,
getModifyPermissionFailure,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
isCreatePermissionPending,
isDeletePermissionPending,
isFetchPermissionsPending,
isModifyPermissionPending,
MODIFY_PERMISSION,
MODIFY_PERMISSION_FAILURE,
MODIFY_PERMISSION_PENDING,
MODIFY_PERMISSION_SUCCESS,
modifyPermission,
modifyPermissionSuccess
} from "./permissions";
import type {Permission, PermissionCollection} from "@scm-manager/ui-types";
const hitchhiker_puzzle42Permission_user_eins: Permission = {
name: "user_eins",
type: "READ",
groupPermission: false,
_links: {
self: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
},
delete: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
},
update: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
}
},
verbs: []
};
const hitchhiker_puzzle42Permission_user_zwei: Permission = {
name: "user_zwei",
type: "WRITE",
groupPermission: true,
_links: {
self: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
},
delete: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
},
update: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
}
},
verbs: []
};
const hitchhiker_puzzle42Permissions: PermissionCollection = [
hitchhiker_puzzle42Permission_user_eins,
hitchhiker_puzzle42Permission_user_zwei
];
const hitchhiker_puzzle42RepoPermissions = {
_embedded: {
permissions: hitchhiker_puzzle42Permissions
},
_links: {
create: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions"
}
}
};
describe("permission fetch", () => {
const REPOS_URL = "/api/v2/repositories";
const URL = "repositories";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch permissions to repo hitchhiker/puzzle42", () => {
fetchMock.getOnce(
REPOS_URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42RepoPermissions
);
const expectedActions = [
{
type: FETCH_PERMISSIONS_PENDING,
payload: {
namespace: "hitchhiker",
repoName: "puzzle42"
},
itemId: "hitchhiker/puzzle42"
},
{
type: FETCH_PERMISSIONS_SUCCESS,
payload: hitchhiker_puzzle42RepoPermissions,
itemId: "hitchhiker/puzzle42"
}
];
const store = mockStore({});
return store
.dispatch(
fetchPermissions(
URL + "/hitchhiker/puzzle42/permissions",
"hitchhiker",
"puzzle42"
)
)
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PERMISSIONS_FAILURE, it the request fails", () => {
fetchMock.getOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 500
});
const store = mockStore({});
return store
.dispatch(
fetchPermissions(
URL + "/hitchhiker/puzzle42/permissions",
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PERMISSIONS_PENDING);
expect(actions[1].type).toEqual(FETCH_PERMISSIONS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully modify user_eins permission", () => {
fetchMock.putOnce(
hitchhiker_puzzle42Permission_user_eins._links.update.href,
{
status: 204
}
);
let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" };
const store = mockStore({});
return store
.dispatch(modifyPermission(editedPermission, "hitchhiker", "puzzle42"))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_SUCCESS);
});
});
it("should successfully modify user_eins permission and call the callback", () => {
fetchMock.putOnce(
hitchhiker_puzzle42Permission_user_eins._links.update.href,
{
status: 204
}
);
let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" };
const store = mockStore({});
let called = false;
const callback = () => {
called = true;
};
return store
.dispatch(
modifyPermission(editedPermission, "hitchhiker", "puzzle42", callback)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_SUCCESS);
expect(called).toBe(true);
});
});
it("should fail modifying on HTTP 500", () => {
fetchMock.putOnce(
hitchhiker_puzzle42Permission_user_eins._links.update.href,
{
status: 500
}
);
let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" };
const store = mockStore({});
return store
.dispatch(modifyPermission(editedPermission, "hitchhiker", "puzzle42"))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should add a permission successfully", () => {
// unmatched
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 204,
headers: {
location: "repositories/hitchhiker/puzzle42/permissions/user_eins"
}
});
fetchMock.getOnce(
REPOS_URL + "/hitchhiker/puzzle42/permissions/user_eins",
hitchhiker_puzzle42Permission_user_eins
);
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_PERMISSION_PENDING);
expect(actions[1].type).toEqual(CREATE_PERMISSION_SUCCESS);
});
});
it("should fail adding a permission on HTTP 500", () => {
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 500
});
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_PERMISSION_PENDING);
expect(actions[1].type).toEqual(CREATE_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should call the callback after permission successfully created", () => {
// unmatched
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 204,
headers: {
location: "repositories/hitchhiker/puzzle42/permissions/user_eins"
}
});
fetchMock.getOnce(
REPOS_URL + "/hitchhiker/puzzle42/permissions/user_eins",
hitchhiker_puzzle42Permission_user_eins
);
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42",
callback
)
)
.then(() => {
expect(callMe).toBe("yeah");
});
});
it("should delete successfully permission user_eins", () => {
fetchMock.deleteOnce(
hitchhiker_puzzle42Permission_user_eins._links.delete.href,
{
status: 204
}
);
const store = mockStore({});
return store
.dispatch(
deletePermission(
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_PERMISSION_PENDING);
expect(actions[0].payload).toBe(
hitchhiker_puzzle42Permission_user_eins
);
expect(actions[1].type).toEqual(DELETE_PERMISSION_SUCCESS);
});
});
it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce(
hitchhiker_puzzle42Permission_user_eins._links.delete.href,
{
status: 204
}
);
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store
.dispatch(
deletePermission(
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42",
callMe
)
)
.then(() => {
expect(called).toBeTruthy();
});
});
it("should fail to delete permission", () => {
fetchMock.deleteOnce(
hitchhiker_puzzle42Permission_user_eins._links.delete.href,
{
status: 500
}
);
const store = mockStore({});
return store
.dispatch(
deletePermission(
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_PERMISSION_PENDING);
expect(actions[0].payload).toBe(
hitchhiker_puzzle42Permission_user_eins
);
expect(actions[1].type).toEqual(DELETE_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("permissions 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 permissions on FETCH_PERMISSION_SUCCESS", () => {
const newState = reducer(
{},
fetchPermissionsSuccess(
hitchhiker_puzzle42RepoPermissions,
"hitchhiker",
"puzzle42"
)
);
expect(newState["hitchhiker/puzzle42"].entries).toBe(
hitchhiker_puzzle42Permissions
);
});
it("should update permission", () => {
const oldState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins]
}
};
let permissionEdited = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" };
let expectedState = {
"hitchhiker/puzzle42": {
entries: [permissionEdited]
}
};
const newState = reducer(
oldState,
modifyPermissionSuccess(permissionEdited, "hitchhiker", "puzzle42")
);
expect(newState["hitchhiker/puzzle42"]).toEqual(
expectedState["hitchhiker/puzzle42"]
);
});
it("should remove permission from state when delete succeeds", () => {
const state = {
"hitchhiker/puzzle42": {
entries: [
hitchhiker_puzzle42Permission_user_eins,
hitchhiker_puzzle42Permission_user_zwei
]
}
};
const expectedState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_zwei]
}
};
const newState = reducer(
state,
deletePermissionSuccess(
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
);
expect(newState["hitchhiker/puzzle42"]).toEqual(
expectedState["hitchhiker/puzzle42"]
);
});
it("should add permission", () => {
//changing state had to be removed because of errors
const oldState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins]
}
};
let expectedState = {
"hitchhiker/puzzle42": {
entries: [
hitchhiker_puzzle42Permission_user_eins,
hitchhiker_puzzle42Permission_user_zwei
]
}
};
const newState = reducer(
oldState,
createPermissionSuccess(
hitchhiker_puzzle42Permission_user_zwei,
"hitchhiker",
"puzzle42"
)
);
expect(newState["hitchhiker/puzzle42"]).toEqual(
expectedState["hitchhiker/puzzle42"]
);
});
});
describe("permissions selectors", () => {
const error = new Error("something goes wrong");
it("should return the permissions of one repository", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
entries: hitchhiker_puzzle42Permissions
}
}
};
const repoPermissions = getPermissionsOfRepo(
state,
"hitchhiker",
"puzzle42"
);
expect(repoPermissions).toEqual(hitchhiker_puzzle42Permissions);
});
it("should return true, when fetch permissions is pending", () => {
const state = {
pending: {
[FETCH_PERMISSIONS + "/hitchhiker/puzzle42"]: true
}
};
expect(isFetchPermissionsPending(state, "hitchhiker", "puzzle42")).toEqual(
true
);
});
it("should return false, when fetch permissions is not pending", () => {
expect(isFetchPermissionsPending({}, "hitchiker", "puzzle42")).toEqual(
false
);
});
it("should return error when fetch permissions did fail", () => {
const state = {
failure: {
[FETCH_PERMISSIONS + "/hitchhiker/puzzle42"]: error
}
};
expect(getFetchPermissionsFailure(state, "hitchhiker", "puzzle42")).toEqual(
error
);
});
it("should return undefined when fetch permissions did not fail", () => {
expect(getFetchPermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(
undefined
);
});
it("should return true, when modify permission is pending", () => {
const state = {
pending: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: true
}
};
expect(
isModifyPermissionPending(
state,
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(true);
});
it("should return false, when modify permission is not pending", () => {
expect(
isModifyPermissionPending(
{},
"hitchiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(false);
});
it("should return error when modify permission did fail", () => {
const state = {
failure: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getModifyPermissionFailure(
state,
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(error);
});
it("should return undefined when modify permission did not fail", () => {
expect(
getModifyPermissionFailure(
{},
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toBe(undefined);
});
it("should return error when one of the modify permissions did fail", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": { entries: hitchhiker_puzzle42Permissions }
},
failure: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getModifyPermissionsFailure(state, "hitchhiker", "puzzle42")
).toEqual(error);
});
it("should return undefined when no modify permissions did not fail", () => {
expect(getModifyPermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(
undefined
);
});
it("should return true, when createPermission is true", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
createPermission: true
}
}
};
expect(hasCreatePermission(state, "hitchhiker", "puzzle42")).toBe(true);
});
it("should return false, when createPermission is false", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
createPermission: false
}
}
};
expect(hasCreatePermission(state, "hitchhiker", "puzzle42")).toEqual(false);
});
it("should return true, when delete permission is pending", () => {
const state = {
pending: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: true
}
};
expect(
isDeletePermissionPending(
state,
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(true);
});
it("should return false, when delete permission is not pending", () => {
expect(
isDeletePermissionPending(
{},
"hitchiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(false);
});
it("should return error when delete permission did fail", () => {
const state = {
failure: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getDeletePermissionFailure(
state,
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toEqual(error);
});
it("should return undefined when delete permission did not fail", () => {
expect(
getDeletePermissionFailure(
{},
"hitchhiker",
"puzzle42",
hitchhiker_puzzle42Permission_user_eins
)
).toBe(undefined);
});
it("should return error when one of the delete permissions did fail", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": { entries: hitchhiker_puzzle42Permissions }
},
failure: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getDeletePermissionsFailure(state, "hitchhiker", "puzzle42")
).toEqual(error);
});
it("should return undefined when no delete permissions did not fail", () => {
expect(getDeletePermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(
undefined
);
});
it("should return true, when create permission is pending", () => {
const state = {
pending: {
[CREATE_PERMISSION + "/hitchhiker/puzzle42"]: true
}
};
expect(isCreatePermissionPending(state, "hitchhiker", "puzzle42")).toEqual(
true
);
});
it("should return false, when create permissions is not pending", () => {
expect(isCreatePermissionPending({}, "hitchiker", "puzzle42")).toEqual(
false
);
});
it("should return error when create permissions did fail", () => {
const state = {
failure: {
[CREATE_PERMISSION + "/hitchhiker/puzzle42"]: error
}
};
expect(getCreatePermissionFailure(state, "hitchhiker", "puzzle42")).toEqual(
error
);
});
it("should return undefined when create permissions did not fail", () => {
expect(getCreatePermissionFailure({}, "hitchhiker", "puzzle42")).toBe(
undefined
);
});
});

View File

@@ -0,0 +1,22 @@
// @flow
import React from "react";
import type { File } from "@scm-manager/ui-types";
type Props = {
file: File
};
class FileIcon extends React.Component<Props> {
render() {
const { file } = this.props;
let icon = "file";
if (file.subRepository) {
icon = "folder-plus";
} else if (file.directory) {
icon = "folder";
}
return <i className={`fa fa-${icon}`} />;
}
}
export default FileIcon;

View File

@@ -0,0 +1,168 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { connect } from "react-redux";
import injectSheet from "react-jss";
import FileTreeLeaf from "./FileTreeLeaf";
import type { Repository, File } from "@scm-manager/ui-types";
import {
ErrorNotification,
Loading,
Notification
} from "@scm-manager/ui-components";
import {
getFetchSourcesFailure,
isFetchSourcesPending,
getSources
} from "../modules/sources";
import { withRouter } from "react-router-dom";
import { compose } from "redux";
import { binder } from "@scm-manager/ui-extensions";
const styles = {
iconColumn: {
width: "16px"
}
};
type Props = {
loading: boolean,
error: Error,
tree: File,
repository: Repository,
revision: string,
path: string,
baseUrl: string,
// context props
classes: any,
t: string => string,
match: any
};
export function findParent(path: string) {
if (path.endsWith("/")) {
path = path.substring(0, path.length - 1);
}
const index = path.lastIndexOf("/");
if (index > 0) {
return path.substring(0, index);
}
return "";
}
class FileTree extends React.Component<Props> {
render() {
const { error, loading, tree } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!tree) {
return null;
}
return <div className="panel-block">{this.renderSourcesTable()}</div>;
}
renderSourcesTable() {
const { tree, revision, path, baseUrl, classes, t } = this.props;
const files = [];
if (path) {
files.push({
name: "..",
path: findParent(path),
directory: true
});
}
const compareFiles = function(f1: File, f2: File): number {
if (f1.directory) {
if (f2.directory) {
return f1.name.localeCompare(f2.name);
} else {
return -1;
}
} else {
if (f2.directory) {
return 1;
} else {
return f1.name.localeCompare(f2.name);
}
}
};
if (tree._embedded && tree._embedded.children) {
files.push(...tree._embedded.children.sort(compareFiles));
}
if (files && files.length > 0) {
let baseUrlWithRevision = baseUrl;
if (revision) {
baseUrlWithRevision += "/" + encodeURIComponent(revision);
} else {
baseUrlWithRevision += "/" + encodeURIComponent(tree.revision);
}
return (
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<th className={classes.iconColumn} />
<th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">
{t("sources.file-tree.length")}
</th>
<th className="is-hidden-mobile">
{t("sources.file-tree.lastModified")}
</th>
<th className="is-hidden-mobile">
{t("sources.file-tree.description")}
</th>
{binder.hasExtension("repos.sources.tree.row.right") && (
<th className="is-hidden-mobile" />
)}
</tr>
</thead>
<tbody>
{files.map(file => (
<FileTreeLeaf
key={file.name}
file={file}
baseUrl={baseUrlWithRevision}
/>
))}
</tbody>
</table>
);
}
return <Notification type="info">{t("sources.noSources")}</Notification>;
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
const loading = isFetchSourcesPending(state, repository, revision, path);
const error = getFetchSourcesFailure(state, repository, revision, path);
const tree = getSources(state, repository, revision, path);
return {
revision,
path,
loading,
error,
tree
};
};
export default compose(
withRouter,
connect(mapStateToProps)
)(injectSheet(styles)(translate("repos")(FileTree)));

View File

@@ -0,0 +1,12 @@
// @flow
import { findParent } from "./FileTree";
describe("find parent tests", () => {
it("should return the parent path", () => {
expect(findParent("src/main/js/")).toBe("src/main");
expect(findParent("src/main/js")).toBe("src/main");
expect(findParent("src/main")).toBe("src");
expect(findParent("src")).toBe("");
});
});

View File

@@ -0,0 +1,110 @@
//@flow
import * as React from "react";
import injectSheet from "react-jss";
import { DateFromNow, FileSize } from "@scm-manager/ui-components";
import FileIcon from "./FileIcon";
import { Link } from "react-router-dom";
import type { File } from "@scm-manager/ui-types";
import classNames from "classnames";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
const styles = {
iconColumn: {
width: "16px"
},
wordBreakMinWidth: {
minWidth: "10em"
}
};
type Props = {
file: File,
baseUrl: string,
// context props
classes: any
};
export function createLink(base: string, file: File) {
let link = base;
if (file.path) {
let path = file.path;
if (path.startsWith("/")) {
path = path.substring(1);
}
link += "/" + path;
}
if (!link.endsWith("/")) {
link += "/";
}
return link;
}
class FileTreeLeaf extends React.Component<Props> {
createLink = (file: File) => {
return createLink(this.props.baseUrl, file);
};
createFileIcon = (file: File) => {
if (file.directory) {
return (
<Link to={this.createLink(file)}>
<FileIcon file={file} />
</Link>
);
}
return (
<Link to={this.createLink(file)}>
<FileIcon file={file} />
</Link>
);
};
createFileName = (file: File) => {
if (file.directory) {
return <Link to={this.createLink(file)}>{file.name}</Link>;
}
return <Link to={this.createLink(file)}>{file.name}</Link>;
};
render() {
const { file, classes } = this.props;
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
return (
<tr>
<td className={classes.iconColumn}>{this.createFileIcon(file)}</td>
<td className={classNames(classes.wordBreakMinWidth, "is-word-break")}>
{this.createFileName(file)}
</td>
<td className="is-hidden-mobile">{fileSize}</td>
<td className="is-hidden-mobile">
<DateFromNow date={file.lastModified} />
</td>
<td
className={classNames(
classes.wordBreakMinWidth,
"is-word-break",
"is-hidden-mobile"
)}
>
{file.description}
</td>
{binder.hasExtension("repos.sources.tree.row.right") && (
<td className="is-hidden-mobile">
{!file.directory && (
<ExtensionPoint
name="repos.sources.tree.row.right"
props={{ file }}
renderAll={true}
/>
)}
</td>
)}
</tr>
);
}
}
export default injectSheet(styles)(FileTreeLeaf);

View File

@@ -0,0 +1,30 @@
// @flow
import { createLink } from "./FileTreeLeaf";
import type { File } from "@scm-manager/ui-types";
describe("create link tests", () => {
function dir(path: string): File {
return {
name: "dir",
path: path,
directory: true,
length: 1,
revision: "1a",
_links: {},
_embedded: {
children: []
}
};
}
it("should create link", () => {
expect(createLink("src", dir("main"))).toBe("src/main/");
expect(createLink("src", dir("/main"))).toBe("src/main/");
expect(createLink("src", dir("/main/"))).toBe("src/main/");
});
it("should return base url if the directory path is empty", () => {
expect(createLink("src", dir(""))).toBe("src/");
});
});

View File

@@ -0,0 +1,26 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { File } from "@scm-manager/ui-types";
import { DownloadButton } from "@scm-manager/ui-components";
type Props = {
t: string => string,
file: File
};
class DownloadViewer extends React.Component<Props> {
render() {
const { t, file } = this.props;
return (
<div className="has-text-centered">
<DownloadButton
url={file._links.self.href}
displayName={t("sources.content.downloadButton")}
/>
</div>
);
}
}
export default translate("repos")(DownloadViewer);

View File

@@ -0,0 +1,57 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { ButtonAddons, Button } from "@scm-manager/ui-components";
type Props = {
t: string => string,
historyIsSelected: boolean,
showHistory: boolean => void
};
class FileButtonGroup extends React.Component<Props> {
showHistory = () => {
this.props.showHistory(true);
};
showSources = () => {
this.props.showHistory(false);
};
color = (selected: boolean) => {
return selected ? "link is-selected" : null;
};
render() {
const { t, historyIsSelected } = this.props;
return (
<ButtonAddons>
<div title={t("sources.content.sourcesButton")}>
<Button
action={this.showSources}
className="reduced"
color={this.color(!historyIsSelected)}
>
<span className="icon">
<i className="fas fa-code" />
</span>
</Button>
</div>
<div title={t("sources.content.historyButton")}>
<Button
action={this.showHistory}
className="reduced"
color={this.color(historyIsSelected)}
>
<span className="icon">
<i className="fas fa-history" />
</span>
</Button>
</div>
</ButtonAddons>
);
}
}
export default translate("repos")(FileButtonGroup);

View File

@@ -0,0 +1,24 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { File } from "@scm-manager/ui-types";
type Props = {
t: string => string,
file: File
};
class ImageViewer extends React.Component<Props> {
render() {
const { file } = this.props;
return (
<div className="has-text-centered">
<figure>
<img src={file._links.self.href} alt={file._links.self.href} />
</figure>
</div>
);
}
}
export default translate("repos")(ImageViewer);

View File

@@ -0,0 +1,92 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { apiClient, SyntaxHighlighter } from "@scm-manager/ui-components";
import type { File } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
type Props = {
t: string => string,
file: File,
language: string
};
type State = {
content: string,
error?: Error,
loaded: boolean
};
class SourcecodeViewer extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
content: "",
loaded: false
};
}
componentDidMount() {
const { file } = this.props;
getContent(file._links.self.href)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
content: result,
loaded: true
});
}
})
.catch(err => {});
}
render() {
const { content, error, loaded } = this.state;
const language = this.props.language;
if (error) {
return <ErrorNotification error={error} />;
}
if (!loaded) {
return <Loading />;
}
if (!content) {
return null;
}
return (
<SyntaxHighlighter
language={getLanguage(language)}
value= {content}
/>
);
}
}
export function getLanguage(language: string) {
return language.toLowerCase();
}
export function getContent(url: string) {
return apiClient
.get(url)
.then(response => response.text())
.then(response => {
return response;
})
.catch(err => {
return { error: err };
});
}
export default translate("repos")(SourcecodeViewer);

View File

@@ -0,0 +1,33 @@
//@flow
import fetchMock from "fetch-mock";
import {
getContent,
getLanguage
} from "./SourcecodeViewer";
describe("get content", () => {
const CONTENT_URL = "/repositories/scmadmin/TestRepo/content/testContent";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should return content", done => {
fetchMock.getOnce("/api/v2" + CONTENT_URL, "This is a testContent");
getContent(CONTENT_URL).then(content => {
expect(content).toBe("This is a testContent");
done();
});
});
});
describe("get correct language type", () => {
it("should return javascript", () => {
expect(getLanguage("JAVASCRIPT")).toBe("javascript");
});
it("should return nothing for plain text", () => {
expect(getLanguage("")).toBe("");
});
});

View File

@@ -0,0 +1,222 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { File, Repository } from "@scm-manager/ui-types";
import {
DateFromNow,
ButtonGroup,
FileSize,
ErrorNotification
} from "@scm-manager/ui-components";
import injectSheet from "react-jss";
import classNames from "classnames";
import FileButtonGroup from "../components/content/FileButtonGroup";
import SourcesView from "./SourcesView";
import HistoryView from "./HistoryView";
import { getSources } from "../modules/sources";
import { connect } from "react-redux";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
loading: boolean,
file: File,
repository: Repository,
revision: string,
path: string,
classes: any,
t: string => string
};
type State = {
collapsed: boolean,
showHistory: boolean,
errorFromExtension?: Error
};
const styles = {
pointer: {
cursor: "pointer"
},
marginInHeader: {
marginRight: "0.5em"
},
isVerticalCenter: {
display: "flex",
alignItems: "center"
},
hasBackground: {
backgroundColor: "#FBFBFB"
}
};
class Content extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
collapsed: true,
showHistory: false
};
}
toggleCollapse = () => {
this.setState(prevState => ({
collapsed: !prevState.collapsed
}));
};
setShowHistoryState(showHistory: boolean) {
this.setState({
...this.state,
showHistory
});
}
handleExtensionError = (error: Error) => {
this.setState({ errorFromExtension: error });
};
showHeader() {
const { file, revision, classes } = this.props;
const { showHistory, collapsed } = this.state;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
const selector = file._links.history ? (
<FileButtonGroup
file={file}
historyIsSelected={showHistory}
showHistory={(changeShowHistory: boolean) =>
this.setShowHistoryState(changeShowHistory)
}
/>
) : null;
return (
<span className={classes.pointer}>
<article className={classNames("media", classes.isVerticalCenter)}>
<div className="media-content" onClick={this.toggleCollapse}>
<i
className={classNames(
"fa is-medium",
icon,
classes.marginInHeader
)}
/>
<span className="is-word-break">{file.name}</span>
</div>
<div className="buttons is-grouped">
<div className={classes.marginInHeader}>{selector}</div>
<ExtensionPoint
name="repos.sources.content.actionbar"
props={{
file,
revision,
handleExtensionError: this.handleExtensionError
}}
renderAll={true}
/>
</div>
</article>
</span>
);
}
showMoreInformation() {
const collapsed = this.state.collapsed;
const { classes, file, revision, t, repository } = this.props;
const date = <DateFromNow date={file.lastModified} />;
const description = file.description ? (
<p>
{file.description.split("\n").map((item, key) => {
return (
<span key={key}>
{item}
<br />
</span>
);
})}
</p>
) : null;
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
if (!collapsed) {
return (
<div className={classNames("panel-block", classes.hasBackground)}>
<table className={classNames("table", classes.hasBackground)}>
<tbody>
<tr>
<td>{t("sources.content.path")}</td>
<td className="is-word-break">{file.path}</td>
</tr>
<tr>
<td>{t("sources.content.branch")}</td>
<td className="is-word-break">{revision}</td>
</tr>
<tr>
<td>{t("sources.content.size")}</td>
<td>{fileSize}</td>
</tr>
<tr>
<td>{t("sources.content.lastModified")}</td>
<td>{date}</td>
</tr>
<tr>
<td>{t("sources.content.description")}</td>
<td className="is-word-break">{description}</td>
</tr>
<ExtensionPoint
name="repos.content.metadata"
renderAll={true}
props={{ file, repository, revision }}
/>
</tbody>
</table>
</div>
);
}
return null;
}
render() {
const { file, revision, repository, path } = this.props;
const { showHistory, errorFromExtension } = this.state;
const header = this.showHeader();
const content =
showHistory && file._links.history ? (
<HistoryView file={file} repository={repository} />
) : (
<SourcesView
revision={revision}
file={file}
repository={repository}
path={path}
/>
);
const moreInformation = this.showMoreInformation();
return (
<div>
<div className="panel">
<div className="panel-heading">{header}</div>
{moreInformation}
{content}
</div>
{errorFromExtension && <ErrorNotification error={errorFromExtension} />}
</div>
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
const file = getSources(state, repository, revision, path);
return {
file
};
};
export default injectSheet(styles)(
connect(mapStateToProps)(translate("repos")(Content))
);

View File

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import type {
File,
Changeset,
Repository,
PagedCollection
} from "@scm-manager/ui-types";
import {
ErrorNotification,
Loading,
StatePaginator,
ChangesetList
} from "@scm-manager/ui-components";
import { getHistory } from "./history";
type Props = {
file: File,
repository: Repository
};
type State = {
loaded: boolean,
changesets: Changeset[],
page: number,
pageCollection?: PagedCollection,
error?: Error
};
class HistoryView extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loaded: false,
page: 1,
changesets: []
};
}
componentDidMount() {
const { file } = this.props;
this.updateHistory(file._links.history.href);
}
updateHistory(link: string) {
getHistory(link)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
loaded: true,
changesets: result.changesets,
pageCollection: result.pageCollection,
page: result.pageCollection.page
});
}
})
.catch(err => {});
}
updatePage(page: number) {
const { file } = this.props;
const internalPage = page - 1;
this.updateHistory(
file._links.history.href + "?page=" + internalPage.toString()
);
}
showHistory() {
const { repository } = this.props;
const { changesets, page, pageCollection } = this.state;
const currentPage = page + 1;
return (
<>
<div className="panel-block">
<ChangesetList repository={repository} changesets={changesets} />
</div>
<div className="panel-footer">
<StatePaginator
page={currentPage}
collection={pageCollection}
updatePage={(newPage: number) => this.updatePage(newPage)}
/>
</div>
</>
);
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const history = this.showHistory();
return <>{history}</>;
}
}
export default HistoryView;

View File

@@ -0,0 +1,228 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { Branch, Repository } from "@scm-manager/ui-types";
import FileTree from "../components/FileTree";
import {
BranchSelector,
Breadcrumb,
ErrorNotification,
Loading
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending
} from "../../branches/modules/branches";
import { compose } from "redux";
import Content from "./Content";
import { fetchSources, isDirectory } from "../modules/sources";
type Props = {
repository: Repository,
loading: boolean,
error: Error,
baseUrl: string,
branches: Branch[],
revision: string,
path: string,
currentFileIsDirectory: boolean,
// dispatch props
fetchBranches: Repository => void,
fetchSources: (Repository, string, string) => void,
// Context props
history: any,
match: any,
location: any,
t: string => string
};
type State = {
selectedBranch: any
};
class Sources extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
selectedBranch: null
};
}
componentDidMount() {
const {
fetchBranches,
repository,
revision,
path,
fetchSources
} = this.props;
fetchBranches(repository);
fetchSources(repository, revision, path);
this.redirectToDefaultBranch();
}
componentDidUpdate(prevProps) {
const { fetchSources, repository, revision, path } = this.props;
if (prevProps.revision !== revision || prevProps.path !== path) {
fetchSources(repository, revision, path);
}
this.redirectToDefaultBranch();
}
redirectToDefaultBranch = () => {
const { branches } = this.props;
if (this.shouldRedirectToDefaultBranch()) {
const defaultBranches = branches.filter(b => b.defaultBranch);
if (defaultBranches.length > 0) {
this.branchSelected(defaultBranches[0]);
}
}
};
shouldRedirectToDefaultBranch = () => {
const { branches, revision } = this.props;
return branches && !revision;
};
branchSelected = (branch?: Branch) => {
const { baseUrl, history, path } = this.props;
let url;
if (branch) {
this.setState({ selectedBranch: branch });
if (path) {
url = `${baseUrl}/${encodeURIComponent(branch.name)}/${path}`;
} else {
url = `${baseUrl}/${encodeURIComponent(branch.name)}/`;
}
} else {
this.setState({ selectedBranch: null });
url = `${baseUrl}/`;
}
history.push(url);
};
render() {
const {
repository,
baseUrl,
branches,
loading,
error,
revision,
path,
currentFileIsDirectory
} = this.props;
const { selectedBranch } = this.state;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (currentFileIsDirectory) {
return (
<div className="panel">
{this.renderBranchSelector()}
<Breadcrumb
revision={encodeURIComponent(revision)}
path={path}
baseUrl={baseUrl}
branch={selectedBranch}
defaultBranch={
branches && branches.filter(b => b.defaultBranch === true)[0]
}
branches={branches && branches}
repository={repository}
/>
<FileTree
repository={repository}
revision={revision}
path={path}
baseUrl={baseUrl}
/>
</div>
);
} else {
return (
<Content repository={repository} revision={revision} path={path} />
);
}
}
renderBranchSelector = () => {
const { branches, revision, t } = this.props;
if (branches) {
return (
<div className="panel-heading">
<BranchSelector
branches={branches}
selectedBranch={revision}
label={t("changesets.branchSelectorLabel")}
selected={(b: Branch) => {
this.branchSelected(b);
}}
/>
</div>
);
}
return null;
};
}
const mapStateToProps = (state, ownProps) => {
const { repository, match } = ownProps;
const { revision, path } = match.params;
const decodedRevision = revision ? decodeURIComponent(revision) : undefined;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const currentFileIsDirectory = decodedRevision
? isDirectory(state, repository, decodedRevision, path)
: isDirectory(state, repository, revision, path);
return {
repository,
revision: decodedRevision,
path,
loading,
error,
branches,
currentFileIsDirectory
};
};
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
},
fetchSources: (repository: Repository, revision: string, path: string) => {
dispatch(fetchSources(repository, revision, path));
}
};
};
export default compose(
translate("repos"),
withRouter,
connect(
mapStateToProps,
mapDispatchToProps
)
)(Sources);

View File

@@ -0,0 +1,97 @@
// @flow
import React from "react";
import SourcecodeViewer from "../components/content/SourcecodeViewer";
import ImageViewer from "../components/content/ImageViewer";
import DownloadViewer from "../components/content/DownloadViewer";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { getContentType } from "./contentType";
import type { File, Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
type Props = {
repository: Repository,
file: File,
revision: string,
path: string
};
type State = {
contentType: string,
language: string,
loaded: boolean,
error?: Error
};
class SourcesView extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
contentType: "",
language: "",
loaded: false
};
}
componentDidMount() {
const { file } = this.props;
getContentType(file._links.self.href)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
contentType: result.type,
language: result.language,
loaded: true
});
}
})
.catch(err => {});
}
showSources() {
const { file, revision } = this.props;
const { contentType, language } = this.state;
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />;
} else {
return (
<ExtensionPoint
name="repos.sources.view"
props={{ file, contentType, revision }}
>
<DownloadViewer file={file} />
</ExtensionPoint>
);
}
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const sources = this.showSources();
return <div className="panel-block">{sources}</div>;
}
}
export default SourcesView;

View File

@@ -0,0 +1,16 @@
//@flow
import { apiClient } from "@scm-manager/ui-components";
export function getContentType(url: string) {
return apiClient
.head(url)
.then(response => {
return {
type: response.headers.get("Content-Type"),
language: response.headers.get("X-Programming-Language")
};
})
.catch(err => {
return { error: err };
});
}

View File

@@ -0,0 +1,29 @@
//@flow
import fetchMock from "fetch-mock";
import { getContentType } from "./contentType";
describe("get content type", () => {
const CONTENT_URL = "/repositories/scmadmin/TestRepo/content/testContent";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should return content", done => {
let headers = {
"Content-Type": "application/text",
"X-Programming-Language": "JAVA"
};
fetchMock.head("/api/v2" + CONTENT_URL, {
headers
});
getContentType(CONTENT_URL).then(content => {
expect(content.type).toBe("application/text");
expect(content.language).toBe("JAVA");
done();
});
});
});

View File

@@ -0,0 +1,22 @@
//@flow
import { apiClient } from "@scm-manager/ui-components";
export function getHistory(url: string) {
return apiClient
.get(url)
.then(response => response.json())
.then(result => {
return {
changesets: result._embedded.changesets,
pageCollection: {
_embedded: result._embedded,
_links: result._links,
page: result.page,
pageTotal: result.pageTotal
}
};
})
.catch(err => {
return { error: err };
});
}

View File

@@ -0,0 +1,53 @@
//@flow
import fetchMock from "fetch-mock";
import { getHistory } from "./history";
describe("get content type", () => {
const FILE_URL = "/repositories/scmadmin/TestRepo/history/file";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
const history = {
page: 0,
pageTotal: 10,
_links: {
self: {
href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10"
},
first: {
href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10"
},
next: {
href: "/repositories/scmadmin/TestRepo/history/file?page=1&pageSize=10"
},
last: {
href: "/repositories/scmadmin/TestRepo/history/file?page=9&pageSize=10"
}
},
_embedded: {
changesets: [
{
id: "1234"
},
{
id: "2345"
}
]
}
};
it("should return history", done => {
fetchMock.get("/api/v2" + FILE_URL, history);
getHistory(FILE_URL).then(content => {
expect(content.changesets).toEqual(history._embedded.changesets);
expect(content.pageCollection.page).toEqual(history.page);
expect(content.pageCollection.pageTotal).toEqual(history.pageTotal);
expect(content.pageCollection._links).toEqual(history._links);
done();
});
});
});

View File

@@ -0,0 +1,154 @@
// @flow
import * as types from "../../../modules/types";
import type { Repository, File, Action } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
export const FETCH_SOURCES = "scm/repos/FETCH_SOURCES";
export const FETCH_SOURCES_PENDING = `${FETCH_SOURCES}_${types.PENDING_SUFFIX}`;
export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`;
export function fetchSources(
repository: Repository,
revision: string,
path: string
) {
return function(dispatch: any) {
dispatch(fetchSourcesPending(repository, revision, path));
return apiClient
.get(createUrl(repository, revision, path))
.then(response => response.json())
.then(sources => {
dispatch(fetchSourcesSuccess(repository, revision, path, sources));
})
.catch(err => {
dispatch(fetchSourcesFailure(repository, revision, path, err));
});
};
}
function createUrl(repository: Repository, revision: string, path: string) {
const base = repository._links.sources.href;
if (!revision && !path) {
return base;
}
// TODO handle trailing slash
const pathDefined = path ? path : "";
return `${base}${encodeURIComponent(revision)}/${pathDefined}`;
}
export function fetchSourcesPending(
repository: Repository,
revision: string,
path: string
): Action {
return {
type: FETCH_SOURCES_PENDING,
itemId: createItemId(repository, revision, path)
};
}
export function fetchSourcesSuccess(
repository: Repository,
revision: string,
path: string,
sources: File
) {
return {
type: FETCH_SOURCES_SUCCESS,
payload: sources,
itemId: createItemId(repository, revision, path)
};
}
export function fetchSourcesFailure(
repository: Repository,
revision: string,
path: string,
error: Error
): Action {
return {
type: FETCH_SOURCES_FAILURE,
payload: error,
itemId: createItemId(repository, revision, path)
};
}
function createItemId(repository: Repository, revision: string, path: string) {
const revPart = revision ? revision : "_";
const pathPart = path ? path : "";
return `${repository.namespace}/${repository.name}/${revPart}/${pathPart}`;
}
// reducer
export default function reducer(
state: any = {},
action: Action = { type: "UNKNOWN" }
): any {
if (action.itemId && action.type === FETCH_SOURCES_SUCCESS) {
return {
...state,
[action.itemId]: action.payload
};
}
return state;
}
// selectors
export function isDirectory(
state: any,
repository: Repository,
revision: string,
path: string
): boolean {
const currentFile = getSources(state, repository, revision, path);
if (currentFile && !currentFile.directory) {
return false;
} else {
return true; //also return true if no currentFile is found since it is the "default" path
}
}
export function getSources(
state: any,
repository: Repository,
revision: string,
path: string
): ?File {
if (state.sources) {
return state.sources[createItemId(repository, revision, path)];
}
return null;
}
export function isFetchSourcesPending(
state: any,
repository: Repository,
revision: string,
path: string
): boolean {
return isPending(
state,
FETCH_SOURCES,
createItemId(repository, revision, path)
);
}
export function getFetchSourcesFailure(
state: any,
repository: Repository,
revision: string,
path: string
): ?Error {
return getFailure(
state,
FETCH_SOURCES,
createItemId(repository, revision, path)
);
}

View File

@@ -0,0 +1,274 @@
// @flow
import type { Repository, File } from "@scm-manager/ui-types";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import {
FETCH_SOURCES,
FETCH_SOURCES_FAILURE,
FETCH_SOURCES_PENDING,
FETCH_SOURCES_SUCCESS,
fetchSources,
getFetchSourcesFailure,
isFetchSourcesPending,
default as reducer,
getSources,
fetchSourcesSuccess,
isDirectory
} from "./sources";
const sourcesUrl =
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/";
const repository: Repository = {
name: "core",
namespace: "scm",
type: "git",
_links: {
sources: {
href: sourcesUrl
}
}
};
const collection = {
name: "src",
path: "src",
directory: true,
description: "foo",
length: 176,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
subRepository: undefined,
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/"
}
},
_embedded: {
children: [
{
name: "src",
path: "src",
directory: true,
description: "",
length: 176,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
lastModified: "",
subRepository: undefined,
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src"
}
},
_embedded: {
children: []
}
},
{
name: "package.json",
path: "package.json",
directory: false,
description: "bump version",
length: 780,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
lastModified: "2017-07-31T11:17:19Z",
subRepository: undefined,
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/content/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json"
},
history: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/history/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json"
}
},
_embedded: {
children: []
}
}
]
}
};
const noDirectory: File = {
name: "src",
path: "src",
directory: true,
length: 176,
revision: "abc",
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src"
}
},
_embedded: {
children: []
}
};
describe("sources fetch", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch the sources of the repository", () => {
fetchMock.getOnce(sourcesUrl, collection);
const expectedActions = [
{ type: FETCH_SOURCES_PENDING, itemId: "scm/core/_/" },
{
type: FETCH_SOURCES_SUCCESS,
itemId: "scm/core/_/",
payload: collection
}
];
const store = mockStore({});
return store.dispatch(fetchSources(repository, "", "")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch the sources of the repository with the given revision and path", () => {
fetchMock.getOnce(sourcesUrl + "abc/src", collection);
const expectedActions = [
{ type: FETCH_SOURCES_PENDING, itemId: "scm/core/abc/src" },
{
type: FETCH_SOURCES_SUCCESS,
itemId: "scm/core/abc/src",
payload: collection
}
];
const store = mockStore({});
return store.dispatch(fetchSources(repository, "abc", "src")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_SOURCES_FAILURE on server error", () => {
fetchMock.getOnce(sourcesUrl, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchSources(repository, "", "")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toBe(FETCH_SOURCES_PENDING);
expect(actions[1].type).toBe(FETCH_SOURCES_FAILURE);
expect(actions[1].itemId).toBe("scm/core/_/");
expect(actions[1].payload).toBeDefined();
});
});
});
describe("reducer tests", () => {
it("should return unmodified state on unknown action", () => {
const state = {};
expect(reducer(state)).toBe(state);
});
it("should store the collection, without revision and path", () => {
const expectedState = {
"scm/core/_/": collection
};
expect(
reducer({}, fetchSourcesSuccess(repository, "", "", collection))
).toEqual(expectedState);
});
it("should store the collection, with revision and path", () => {
const expectedState = {
"scm/core/abc/src/main": collection
};
expect(
reducer(
{},
fetchSourcesSuccess(repository, "abc", "src/main", collection)
)
).toEqual(expectedState);
});
});
describe("selector tests", () => {
it("should return false if it is no directory", () => {
const state = {
sources: {
"scm/core/abc/src/main/package.json": {
noDirectory
}
}
};
expect(
isDirectory(state, repository, "abc", "src/main/package.json")
).toBeFalsy();
});
it("should return true if it is directory", () => {
const state = {
sources: {
"scm/core/abc/src": noDirectory
}
};
expect(isDirectory(state, repository, "abc", "src")).toBe(true);
});
it("should return null", () => {
expect(getSources({}, repository, "", "")).toBeFalsy();
});
it("should return the source collection without revision and path", () => {
const state = {
sources: {
"scm/core/_/": collection
}
};
expect(getSources(state, repository, "", "")).toBe(collection);
});
it("should return the source collection with revision and path", () => {
const state = {
sources: {
"scm/core/abc/src/main": collection
}
};
expect(getSources(state, repository, "abc", "src/main")).toBe(collection);
});
it("should return true, when fetch sources is pending", () => {
const state = {
pending: {
[FETCH_SOURCES + "/scm/core/_/"]: true
}
};
expect(isFetchSourcesPending(state, repository, "", "")).toEqual(true);
});
it("should return false, when fetch sources is not pending", () => {
expect(isFetchSourcesPending({}, repository, "", "")).toEqual(false);
});
const error = new Error("incredible error from hell");
it("should return error when fetch sources did fail", () => {
const state = {
failure: {
[FETCH_SOURCES + "/scm/core/_/"]: error
}
};
expect(getFetchSourcesFailure(state, repository, "", "")).toEqual(error);
});
it("should return undefined when fetch sources did not fail", () => {
expect(getFetchSourcesFailure({}, repository, "", "")).toBe(undefined);
});
});