merge with branch feature/repositories-ui

This commit is contained in:
Sebastian Sdorra
2018-08-07 16:34:26 +02:00
93 changed files with 3954 additions and 275 deletions

View File

@@ -35,6 +35,7 @@ package sonia.scm;
//~--- non-JDK imports --------------------------------------------------------
import sonia.scm.repository.RepositoryType;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.User;
@@ -82,9 +83,9 @@ public final class ScmState
* @since 2.0.0
*/
public ScmState(String version, User user, Collection<String> groups,
String token, Collection<Type> repositoryTypes, String defaultUserType,
ScmClientConfig clientConfig, List<String> assignedPermission,
List<PermissionDescriptor> availablePermissions)
String token, Collection<RepositoryType> repositoryTypes, String defaultUserType,
ScmClientConfig clientConfig, List<String> assignedPermission,
List<PermissionDescriptor> availablePermissions)
{
this.version = version;
this.user = user;
@@ -165,7 +166,7 @@ public final class ScmState
*
* @return all available repository types
*/
public Collection<Type> getRepositoryTypes()
public Collection<RepositoryType> getRepositoryTypes()
{
return repositoryTypes;
}
@@ -244,7 +245,7 @@ public final class ScmState
/** Field description */
@XmlElement(name = "repositoryTypes")
private Collection<Type> repositoryTypes;
private Collection<RepositoryType> repositoryTypes;
/** Field description */
private User user;

View File

@@ -5,7 +5,7 @@ import com.google.common.base.Strings;
import java.util.Objects;
public class NamespaceAndName {
public class NamespaceAndName implements Comparable<NamespaceAndName> {
private final String namespace;
private final String name;
@@ -47,4 +47,13 @@ public class NamespaceAndName {
public int hashCode() {
return Objects.hash(namespace, name);
}
@Override
public int compareTo(NamespaceAndName o) {
int result = namespace.compareTo(o.namespace);
if (result == 0) {
return name.compareTo(o.name);
}
return result;
}
}

View File

@@ -2,7 +2,18 @@ package sonia.scm.repository;
import sonia.scm.plugin.ExtensionPoint;
/**
* Strategy to create a namespace for the new repository. Namespaces are used to order and identify repositories.
*/
@ExtensionPoint
public interface NamespaceStrategy {
String getNamespace();
/**
* Create new namespace for the given repository.
*
* @param repository repository
*
* @return namespace
*/
String createNamespace(Repository repository);
}

View File

@@ -82,4 +82,7 @@ public interface RepositoryHandler
* @since 1.15
*/
public String getVersionInformation();
@Override
RepositoryType getType();
}

View File

@@ -35,7 +35,6 @@ package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
import sonia.scm.Type;
import sonia.scm.TypeManager;
import javax.servlet.http.HttpServletRequest;
@@ -99,7 +98,7 @@ public interface RepositoryManager
*
* @return all configured repository types
*/
public Collection<Type> getConfiguredTypes();
public Collection<RepositoryType> getConfiguredTypes();
/**
* Returns the {@link Repository} associated to the request uri.

View File

@@ -103,7 +103,7 @@ public class RepositoryManagerDecorator
* @return
*/
@Override
public Collection<Type> getConfiguredTypes()
public Collection<RepositoryType> getConfiguredTypes()
{
return decorated.getConfiguredTypes();
}

View File

@@ -6,6 +6,7 @@ import javax.ws.rs.core.MediaType;
* Vendor media types used by SCMM.
*/
public class VndMediaType {
private static final String VERSION = "2";
private static final String TYPE = "application";
private static final String SUBTYPE_PREFIX = "vnd.scmm-";
@@ -18,8 +19,10 @@ public class VndMediaType {
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
public static final String CONFIG = PREFIX + "config" + SUFFIX;
public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX;
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
public static final String ME = PREFIX + "me" + SUFFIX;
private VndMediaType() {
}

View File

@@ -41,7 +41,6 @@ import com.google.inject.Singleton;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import sonia.scm.Type;
import sonia.scm.io.FileSystem;
import sonia.scm.plugin.Extension;
import sonia.scm.repository.spi.GitRepositoryServiceProvider;
@@ -88,7 +87,7 @@ public class GitRepositoryHandler
private static final Logger logger = LoggerFactory.getLogger(GitRepositoryHandler.class);
/** Field description */
public static final Type TYPE = new RepositoryType(TYPE_NAME,
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
TYPE_DISPLAYNAME,
GitRepositoryServiceProvider.COMMANDS);
@@ -167,7 +166,7 @@ public class GitRepositoryHandler
* @return
*/
@Override
public Type getType()
public RepositoryType getType()
{
return TYPE;
}

View File

@@ -44,7 +44,6 @@ import org.slf4j.LoggerFactory;
import sonia.scm.ConfigurationException;
import sonia.scm.SCMContextProvider;
import sonia.scm.Type;
import sonia.scm.installer.HgInstaller;
import sonia.scm.installer.HgInstallerFactory;
import sonia.scm.io.DirectoryFileFilter;
@@ -98,7 +97,7 @@ public class HgRepositoryHandler
public static final String TYPE_NAME = "hg";
/** Field description */
public static final Type TYPE = new RepositoryType(TYPE_NAME,
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
TYPE_DISPLAYNAME,
HgRepositoryServiceProvider.COMMANDS,
HgRepositoryServiceProvider.FEATURES);
@@ -259,7 +258,7 @@ public class HgRepositoryHandler
* @return
*/
@Override
public Type getType()
public RepositoryType getType()
{
return TYPE;
}

View File

@@ -49,7 +49,6 @@ import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
import org.tmatesoft.svn.util.SVNDebugLog;
import sonia.scm.Type;
import sonia.scm.io.FileSystem;
import sonia.scm.logging.SVNKitLogger;
import sonia.scm.plugin.Extension;
@@ -87,7 +86,7 @@ public class SvnRepositoryHandler
public static final String TYPE_NAME = "svn";
/** Field description */
public static final Type TYPE = new RepositoryType(TYPE_NAME,
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
TYPE_DISPLAYNAME,
SvnRepositoryServiceProvider.COMMANDS);
@@ -150,7 +149,7 @@ public class SvnRepositoryHandler
* @return
*/
@Override
public Type getType()
public RepositoryType getType()
{
return TYPE;
}

View File

@@ -33,7 +33,7 @@ package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
import sonia.scm.Type;
import com.google.common.collect.Sets;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.store.ConfigurationStoreFactory;
@@ -55,7 +55,7 @@ public class DummyRepositoryHandler
public static final String TYPE_NAME = "dummy";
public static final Type TYPE = new Type(TYPE_NAME, TYPE_DISPLAYNAME);
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, Sets.newHashSet());
private final Set<String> existingRepoNames = new HashSet<>();
@@ -64,7 +64,7 @@ public class DummyRepositoryHandler
}
@Override
public Type getType() {
public RepositoryType getType() {
return TYPE;
}

View File

@@ -6,10 +6,12 @@
"dependencies": {
"bulma": "^0.7.1",
"classnames": "^2.2.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"i18next": "^11.4.0",
"i18next-browser-languagedetector": "^2.2.2",
"i18next-fetch-backend": "^0.1.0",
"moment": "^2.22.2",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-i18next": "^7.9.0",

View File

@@ -0,0 +1,46 @@
{
"repository": {
"name": "Name",
"type": "Type",
"contact": "Contact",
"description": "Description",
"creationDate": "Creation Date",
"lastModified": "Last Modified"
},
"validation": {
"name-invalid": "The repository name is invalid",
"contact-invalid": "Contact must be a valid mail address"
},
"overview": {
"title": "Repositories",
"subtitle": "Overview of available repositories",
"create-button": "Create"
},
"repository-root": {
"error-title": "Error",
"error-subtitle": "Unknown repository error",
"actions-label": "Actions",
"back-label": "Back",
"navigation-label": "Navigation",
"information": "Information"
},
"create": {
"title": "Create Repository",
"subtitle": "Create a new repository"
},
"repository-form": {
"submit": "Save"
},
"edit-nav-link": {
"label": "Edit"
},
"delete-nav-action": {
"label": "Delete",
"confirm-alert": {
"title": "Delete repository",
"message": "Do you really want to delete the repository?",
"submit": "Yes",
"cancel": "No"
}
}
}

View File

@@ -1,7 +0,0 @@
{
"repositories": {
"title": "Repositories",
"subtitle": "Repositories will be shown here",
"body": "Coming soon ..."
}
}

View File

@@ -5,7 +5,10 @@
"mail": "E-Mail",
"password": "Password",
"admin": "Admin",
"active": "Active"
"active": "Active",
"type": "Type",
"creationDate": "Creation Date",
"lastModified": "Last Modified"
},
"users": {
"title": "Users",

View File

@@ -27,7 +27,7 @@ function handleStatusCode(response: Response) {
}
export function createUrl(url: string) {
if (url.indexOf("://") > 0) {
if (url.includes("://")) {
return url;
}
let urlWithStartingSlash = url;
@@ -42,26 +42,12 @@ class ApiClient {
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
}
post(url: string, payload: any) {
return this.httpRequestWithJSONBody(url, payload, "POST");
post(url: string, payload: any, contentType: string = "application/json") {
return this.httpRequestWithJSONBody("POST", url, contentType, payload);
}
postWithContentType(url: string, payload: any, contentType: string) {
return this.httpRequestWithContentType(
url,
"POST",
JSON.stringify(payload),
contentType
);
}
putWithContentType(url: string, payload: any, contentType: string) {
return this.httpRequestWithContentType(
url,
"PUT",
JSON.stringify(payload),
contentType
);
put(url: string, payload: any, contentType: string = "application/json") {
return this.httpRequestWithJSONBody("PUT", url, contentType, payload);
}
delete(url: string): Promise<Response> {
@@ -73,37 +59,14 @@ class ApiClient {
}
httpRequestWithJSONBody(
url: string,
payload: any,
method: string
): Promise<Response> {
// let options: RequestOptions = {
// method: method,
// body: JSON.stringify(payload)
// };
// options = Object.assign(options, fetchOptions);
// // $FlowFixMe
// options.headers["Content-Type"] = "application/json";
// return fetch(createUrl(url), options).then(handleStatusCode);
return this.httpRequestWithContentType(
url,
method,
JSON.stringify(payload),
"application/json"
).then(handleStatusCode);
}
httpRequestWithContentType(
url: string,
method: string,
payload: any,
contentType: string
url: string,
contentType: string,
payload: any
): Promise<Response> {
let options: RequestOptions = {
method: method,
body: payload
body: JSON.stringify(payload)
};
options = Object.assign(options, fetchOptions);
// $FlowFixMe

View File

@@ -0,0 +1,32 @@
//@flow
import React from "react";
import moment from "moment";
import { translate } from "react-i18next";
type Props = {
date?: string,
// context props
i18n: any
};
class DateFromNow extends React.Component<Props> {
static format(locale: string, date?: string) {
let fromNow = "";
if (date) {
fromNow = moment(date)
.locale(locale)
.fromNow();
}
return fromNow;
}
render() {
const { i18n, date } = this.props;
const fromNow = DateFromNow.format(i18n.language, date);
return <span>{fromNow}</span>;
}
}
export default translate()(DateFromNow);

View File

@@ -0,0 +1,18 @@
// @flow
import React from "react";
type Props = {
address?: string
};
class MailLink extends React.Component<Props> {
render() {
const { address } = this.props;
if (!address) {
return null;
}
return <a href={"mailto: " + address}>{address}</a>;
}
}
export default MailLink;

View File

@@ -1,11 +0,0 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class AddButton extends React.Component<ButtonProps> {
render() {
return <Button type="default" {...this.props} />;
}
}
export default AddButton;

View File

@@ -10,7 +10,8 @@ export type ButtonProps = {
action?: () => void,
link?: string,
fullWidth?: boolean,
className?: string
className?: string,
classes: any
};
type Props = ButtonProps & {

View File

@@ -0,0 +1,24 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import Button, { type ButtonProps } from "./Button";
import classNames from "classnames";
const styles = {
spacing: {
margin: "1em 0 0 1em"
}
};
class CreateButton extends React.Component<ButtonProps> {
render() {
const { classes } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<Button type="default" {...this.props} />
</div>
);
}
}
export default injectSheet(styles)(CreateButton);

View File

@@ -1,4 +1,4 @@
export { default as AddButton } from "./AddButton";
export { default as CreateButton } from "./CreateButton";
export { default as Button } from "./Button";
export { default as DeleteButton } from "./DeleteButton";
export { default as EditButton } from "./EditButton";

View File

@@ -0,0 +1,67 @@
//@flow
import React from "react";
export type SelectItem = {
value: string,
label: string
};
type Props = {
label?: string,
options: SelectItem[],
value?: SelectItem,
onChange: string => void
};
class Select extends React.Component<Props> {
field: ?HTMLSelectElement;
componentDidMount() {
// trigger change after render, if value is null to set it to the first value
// of the given options.
if (!this.props.value && this.field && this.field.value) {
this.props.onChange(this.field.value);
}
}
handleInput = (event: SyntheticInputEvent<HTMLSelectElement>) => {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
render() {
const { options, value } = this.props;
return (
<div className="field">
{this.renderLabel()}
<div className="control select">
<select
ref={input => {
this.field = input;
}}
value={value}
onChange={this.handleInput}
>
{options.map(opt => {
return (
<option value={opt.value} key={opt.value}>
{opt.label}
</option>
);
})}
</select>
</div>
</div>
);
}
}
export default Select;

View File

@@ -0,0 +1,53 @@
//@flow
import React from "react";
export type SelectItem = {
value: string,
label: string
};
type Props = {
label?: string,
placeholder?: SelectItem[],
value?: string,
onChange: string => void
};
class Textarea extends React.Component<Props> {
field: ?HTMLTextAreaElement;
handleInput = (event: SyntheticInputEvent<HTMLTextAreaElement>) => {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
render() {
const { placeholder, value } = this.props;
return (
<div className="field">
{this.renderLabel()}
<div className="control">
<textarea
className="textarea"
ref={input => {
this.field = input;
}}
placeholder={placeholder}
onChange={this.handleInput}
value={value}
/>
</div>
</div>
);
}
}
export default Textarea;

View File

@@ -1,2 +1,3 @@
export { default as Checkbox } from "./Checkbox";
export { default as InputField } from "./InputField";
export { default as Select } from "./Select";

View File

@@ -8,6 +8,7 @@ type Props = {
subtitle?: string,
loading?: boolean,
error?: Error,
showContentOnError?: boolean,
children: React.Node
};
@@ -35,8 +36,8 @@ class Page extends React.Component<Props> {
}
renderContent() {
const { loading, children, error } = this.props;
if (error) {
const { loading, children, showContentOnError, error } = this.props;
if (error && !showContentOnError) {
return null;
}
if (loading) {

View File

@@ -14,8 +14,8 @@ class PrimaryNavigation extends React.Component<Props> {
<nav className="tabs is-boxed">
<ul>
<PrimaryNavigationLink
to="/"
activeOnlyWhenExact={true}
to="/repos"
match="/(repo|repos)"
label={t("primary-navigation.repositories")}
/>
<PrimaryNavigationLink

View File

@@ -0,0 +1,12 @@
// @flow
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
export const isMailValid = (mail: string) => {
return mailRegex.test(mail);
};

View File

@@ -0,0 +1,75 @@
import * as validator from "./validation";
describe("test name validation", () => {
it("should return false", () => {
// invalid names taken from ValidationUtilTest.java
const invalidNames = [
" test 123",
" test 123 ",
"test 123 ",
"test/123",
"test%123",
"test:123",
"t ",
" t",
" t ",
""
];
for (let name of invalidNames) {
expect(validator.isNameValid(name)).toBe(false);
}
});
it("should return true", () => {
// valid names taken from ValidationUtilTest.java
const validNames = [
"test",
"test.git",
"Test123.git",
"Test123-git",
"Test_user-123.git",
"test@scm-manager.de",
"test 123",
"tt",
"t"
];
for (let name of validNames) {
expect(validator.isNameValid(name)).toBe(true);
}
});
});
describe("test mail validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java
const invalid = [
"ostfalia.de",
"@ostfalia.de",
"s.sdorra@",
"s.sdorra@ostfalia",
"s.sdorra@@ostfalia.de",
"s.sdorra@ ostfalia.de",
"s.sdorra @ostfalia.de"
];
for (let mail of invalid) {
expect(validator.isMailValid(mail)).toBe(false);
}
});
it("should return true", () => {
// valid taken from ValidationUtilTest.java
const valid = [
"s.sdorra@ostfalia.de",
"sdorra@ostfalia.de",
"s.sdorra@hbk-bs.de",
"s.sdorra@gmail.com",
"s.sdorra@t.co",
"s.sdorra@ucla.college",
"s.sdorra@example.xn--p1ai",
"s.sdorra@scm.solutions"
];
for (let mail of valid) {
expect(validator.isMailValid(mail)).toBe(true);
}
});
});

View File

@@ -13,9 +13,11 @@
margin: 0 !important;
padding: 0 0 0 3.8em !important; }
html, body {
background-color: whitesmoke;
height: 100%; }
.main {
min-height: calc(100vh - 260px); }
.footer {
height: 50px; }
/*! bulma.io v0.7.1 | MIT License | github.com/jgthms/bulma */
@keyframes spinAround {
@@ -6437,3 +6439,9 @@ label.panel-block {
.footer {
background-color: #fafafa;
padding: 3rem 1.5rem 6rem; }
.box-link-shadow:hover, .box-link-shadow:focus {
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px #33B2E8; }
.box-link-shadow:active {
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.2), 0 0 0 1px #33B2E8; }

View File

@@ -12,6 +12,7 @@ import {
} from "../modules/auth";
import "./App.css";
import "font-awesome/css/font-awesome.css";
import "../components/modals/ConfirmAlert.css";
import { PrimaryNavigation } from "../components/navigation";
import Loading from "../components/Loading";

View File

@@ -26,10 +26,23 @@ $blue: #33B2E8;
padding: 0 0 0 3.8em !important;
}
html, body {
background-color: whitesmoke;
height: 100%;
.main {
min-height: calc(100vh - 260px);
}
.footer {
height: 50px;
}
// 6. Import the rest of Bulma
@import "bulma/bulma";
// import at the end, because we need a lot of stuff from bulma/bulma
.box-link-shadow {
&:hover,
&:focus {
box-shadow: $box-link-hover-shadow;
}
&:active {
box-shadow: $box-link-active-shadow;
}
}

View File

@@ -98,7 +98,7 @@ class Login extends React.Component<Props, State> {
}
return (
<section className="hero has-background-light">
<section className="hero">
<div className="hero-body">
<div className="container has-text-centered">
<div className="column is-4 is-offset-4">

View File

@@ -1,9 +1,9 @@
//@flow
import React from "react";
import { Route, withRouter } from "react-router";
import { Route, Redirect, withRouter } from "react-router";
import Repositories from "../repositories/containers/Repositories";
import Overview from "../repos/containers/Overview";
import Users from "../users/containers/Users";
import Login from "../containers/Login";
import Logout from "../containers/Logout";
@@ -12,6 +12,8 @@ import { Switch } from "react-router-dom";
import ProtectedRoute from "../components/ProtectedRoute";
import AddUser from "../users/containers/AddUser";
import SingleUser from "../users/containers/SingleUser";
import RepositoryRoot from "../repos/containers/RepositoryRoot";
import Create from "../repos/containers/Create";
type Props = {
authenticated?: boolean
@@ -21,16 +23,34 @@ class Main extends React.Component<Props> {
render() {
const { authenticated } = this.props;
return (
<div>
<div className="main">
<Switch>
<ProtectedRoute
exact
path="/"
component={Repositories}
authenticated={authenticated}
/>
<Redirect exact path="/" to="/repos" />
<Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<ProtectedRoute
exact
path="/repos"
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repos/create"
component={Create}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repos/:page"
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
path="/repo/:namespace/:name"
component={RepositoryRoot}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/users"

View File

@@ -5,6 +5,8 @@ import { createStore, compose, applyMiddleware, combineReducers } from "redux";
import { routerReducer, routerMiddleware } from "react-router-redux";
import users from "./users/modules/users";
import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes";
import auth from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
@@ -20,6 +22,8 @@ function createReduxStore(history: BrowserHistory) {
pending,
failure,
users,
repos,
repositoryTypes,
auth
});

View File

@@ -5,6 +5,8 @@ import { reactI18nextModule } from "react-i18next";
const loadPath = process.env.PUBLIC_URL + "/locales/{{lng}}/{{ns}}.json";
// TODO load locales for moment
i18n
.use(Backend)
.use(LanguageDetector)

View File

@@ -0,0 +1,59 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { confirmAlert } from "../../components/modals/ConfirmAlert";
import { NavAction } from "../../components/navigation";
import type { Repository } from "../types/Repositories";
type Props = {
repository: Repository,
confirmDialog?: boolean,
delete: Repository => void,
// context props
t: string => string
};
class DeleteNavAction extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
delete = () => {
this.props.delete(this.props.repository);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("delete-nav-action.confirm-alert.title"),
message: t("delete-nav-action.confirm-alert.message"),
buttons: [
{
label: t("delete-nav-action.confirm-alert.submit"),
onClick: () => this.delete()
},
{
label: t("delete-nav-action.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.repository._links.delete;
};
render() {
const { confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.delete();
if (!this.isDeletable()) {
return null;
}
return <NavAction label={t("delete-nav-action.label")} action={action} />;
}
}
export default translate("repos")(DeleteNavAction);

View File

@@ -0,0 +1,79 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import DeleteNavAction from "./DeleteNavAction";
import { confirmAlert } from "../../components/modals/ConfirmAlert";
jest.mock("../../components/modals/ConfirmAlert");
describe("DeleteNavAction", () => {
it("should render nothing, if the delete link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<DeleteNavAction repository={repository} delete={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
delete: {
href: "/repositories"
}
}
};
const navLink = mount(
<DeleteNavAction repository={repository} delete={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const repository = {
_links: {
delete: {
href: "/repositorys"
}
}
};
const navLink = mount(
<DeleteNavAction repository={repository} delete={() => {}} />
);
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete repository function with delete url", () => {
const repository = {
_links: {
delete: {
href: "/repos"
}
}
};
let calledUrl = null;
function capture(repository) {
calledUrl = repository._links.delete.href;
}
const navLink = mount(
<DeleteNavAction
repository={repository}
confirmDialog={false}
delete={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/repos");
});
});

View File

@@ -0,0 +1,22 @@
//@flow
import React from "react";
import { NavLink } from "../../components/navigation";
import { translate } from "react-i18next";
import type { Repository } from "../types/Repositories";
type Props = { editUrl: string, t: string => string, repository: Repository };
class EditNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.repository._links.update;
};
render() {
if (!this.isEditable()) {
return null;
}
const { editUrl, t } = this.props;
return <NavLink to={editUrl} label={t("edit-nav-link.label")} />;
}
}
export default translate("repos")(EditNavLink);

View File

@@ -0,0 +1,32 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import EditNavLink from "./EditNavLink";
jest.mock("../../components/modals/ConfirmAlert");
jest.mock("../../components/navigation/NavLink", () => () => <div>foo</div>);
describe("EditNavLink", () => {
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(<EditNavLink repository={repository} editUrl="" />);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
update: {
href: "/repositories"
}
}
};
const navLink = mount(<EditNavLink repository={repository} editUrl="" />);
expect(navLink.text()).toBe("foo");
});
});

View File

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

View File

@@ -0,0 +1,168 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { InputField, Select } from "../../../components/forms/index";
import { SubmitButton } from "../../../components/buttons/index";
import type { Repository } from "../../types/Repositories";
import * as validator from "./repositoryValidation";
import type { RepositoryType } from "../../types/RepositoryTypes";
import Textarea from "../../../components/forms/Textarea";
type Props = {
submitForm: Repository => void,
repository?: Repository,
repositoryTypes: RepositoryType[],
loading?: boolean,
t: string => string
};
type State = {
repository: Repository,
nameValidationError: boolean,
contactValidationError: boolean
};
class RepositoryForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
repository: {
name: "",
namespace: "",
type: "",
contact: "",
description: "",
_links: {}
},
nameValidationError: false,
contactValidationError: false,
descriptionValidationError: false
};
}
componentDidMount() {
const { repository } = this.props;
if (repository) {
this.setState({ repository: { ...repository } });
}
}
isFalsy(value) {
if (!value) {
return true;
}
return false;
}
isValid = () => {
const repository = this.state.repository;
return !(
this.state.nameValidationError ||
this.state.contactValidationError ||
this.isFalsy(repository.name)
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.repository);
}
};
isCreateMode = () => {
return !this.props.repository;
};
render() {
const { loading, t } = this.props;
const repository = this.state.repository;
return (
<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")}
/>
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
/>
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("repository-form.submit")}
/>
</form>
);
}
createSelectOptions(repositoryTypes: RepositoryType[]) {
return repositoryTypes.map(repositoryType => {
return {
label: repositoryType.displayName,
value: repositoryType.name
};
});
}
renderCreateOnlyFields() {
if (!this.isCreateMode()) {
return null;
}
const { repositoryTypes, t } = this.props;
const repository = this.state.repository;
return (
<div>
<InputField
label={t("repository.name")}
onChange={this.handleNameChange}
value={repository ? repository.name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.name-invalid")}
/>
<Select
label={t("repository.type")}
onChange={this.handleTypeChange}
value={repository ? repository.type : ""}
options={this.createSelectOptions(repositoryTypes)}
/>
</div>
);
}
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,10 @@
// @flow
import * as generalValidator from "../../../components/validation";
export const isNameValid = (name: string) => {
return generalValidator.isNameValid(name);
};
export function isContactValid(mail: string) {
return "" === mail || generalValidator.isMailValid(mail);
}

View File

@@ -0,0 +1,31 @@
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);
});
});
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,119 @@
//@flow
import React from "react";
import { Link } from "react-router-dom";
import injectSheet from "react-jss";
import type { Repository } from "../../types/Repositories";
import DateFromNow from "../../../components/DateFromNow";
import RepositoryEntryLink from "./RepositoryEntryLink";
import classNames from "classnames";
import icon from "../../../images/blib.jpg";
const styles = {
outer: {
position: "relative"
},
overlay: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
right: 0
},
inner: {
position: "relative",
pointerEvents: "none",
zIndex: 1
},
innerLink: {
pointerEvents: "all"
}
};
type Props = {
repository: Repository,
// context props
classes: any
};
class RepositoryEntry extends React.Component<Props> {
createLink = (repository: Repository) => {
return `/repo/${repository.namespace}/${repository.name}`;
};
renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["changesets"]) {
return (
<RepositoryEntryLink
iconClass="fa-code-fork"
to={repositoryLink + "/changesets"}
/>
);
}
return null;
};
renderSourcesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["sources"]) {
return (
<RepositoryEntryLink
iconClass="fa-code"
to={repositoryLink + "/sources"}
/>
);
}
return null;
};
renderModifyLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["update"]) {
return (
<RepositoryEntryLink
iconClass="fa-cog"
to={repositoryLink + "/modify"}
/>
);
}
return null;
};
render() {
const { repository, classes } = this.props;
const repositoryLink = this.createLink(repository);
return (
<div className={classNames("box", "box-link-shadow", classes.outer)}>
<Link className={classes.overlay} to={repositoryLink} />
<article className={classNames("media", classes.inner)}>
<figure className="media-left">
<p className="image is-64x64">
<img src={icon} alt="Logo" />
</p>
</figure>
<div className="media-content">
<div className="content">
<p>
<strong>{repository.name}</strong>
<br />
{repository.description}
</p>
</div>
<nav className="level is-mobile">
<div className="level-left">
{this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)}
{this.renderModifyLink(repository, repositoryLink)}
</div>
<div className="level-right is-hidden-mobile">
<small className="level-item">
<DateFromNow date={repository.creationDate} />
</small>
</div>
</nav>
</div>
</article>
</div>
);
}
}
export default injectSheet(styles)(RepositoryEntry);

View File

@@ -0,0 +1,34 @@
//@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"
}
};
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,67 @@
//@flow
import React from "react";
import type { RepositoryGroup } from "../../types/Repositories";
import injectSheet from "react-jss";
import classNames from "classnames";
import RepositoryEntry from "./RepositoryEntry";
const styles = {
pointer: {
cursor: "pointer"
},
repoGroup: {
marginBottom: "1em"
}
};
type Props = {
group: RepositoryGroup,
// context props
classes: any
};
type State = {
collapsed: boolean
};
class RepositoryGroupEntry extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
collapsed: false
};
}
toggleCollapse = () => {
this.setState(prevState => ({
collapsed: !prevState.collapsed
}));
};
render() {
const { group, classes } = this.props;
const { collapsed } = this.state;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
let content = null;
if (!collapsed) {
content = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
});
}
return (
<div className={classes.repoGroup}>
<h2>
<span className={classes.pointer} onClick={this.toggleCollapse}>
<i className={classNames("fa", icon)} /> {group.name}
</span>
</h2>
<hr />
{content}
</div>
);
}
}
export default injectSheet(styles)(RepositoryGroupEntry);

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "../../types/Repositories";
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 "../../types/Repositories";
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,111 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Page } from "../../components/layout";
import RepositoryForm from "../components/form";
import type { RepositoryType } from "../types/RepositoryTypes";
import {
fetchRepositoryTypesIfNeeded,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending
} from "../modules/repositoryTypes";
import {
createRepo,
createRepoReset,
getCreateRepoFailure,
isCreateRepoPending
} from "../modules/repos";
import type { Repository } from "../types/Repositories";
import type { History } from "history";
type Props = {
repositoryTypes: RepositoryType[],
typesLoading: boolean,
createLoading: boolean,
error: Error,
// dispatch functions
fetchRepositoryTypesIfNeeded: () => void,
createRepo: (Repository, callback: () => void) => void,
resetForm: () => void,
// context props
t: string => string,
history: History
};
class Create extends React.Component<Props> {
componentDidMount() {
this.props.resetForm();
this.props.fetchRepositoryTypesIfNeeded();
}
repoCreated = () => {
const { history } = this.props;
history.push("/repos");
};
render() {
const {
typesLoading,
createLoading,
repositoryTypes,
createRepo,
error
} = this.props;
const { t } = this.props;
return (
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
loading={typesLoading}
error={error}
showContentOnError={true}
>
<RepositoryForm
repositoryTypes={repositoryTypes}
loading={createLoading}
submitForm={repo => {
createRepo(repo, this.repoCreated);
}}
/>
</Page>
);
}
}
const mapStateToProps = state => {
const repositoryTypes = getRepositoryTypes(state);
const typesLoading = isFetchRepositoryTypesPending(state);
const createLoading = isCreateRepoPending(state);
const error =
getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state);
return {
repositoryTypes,
typesLoading,
createLoading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded());
},
createRepo: (repository: Repository, callback: () => void) => {
dispatch(createRepo(repository, callback));
},
resetForm: () => {
dispatch(createRepoReset());
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(Create));

View File

@@ -0,0 +1,71 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import RepositoryForm from "../components/form";
import type { Repository } from "../types/Repositories";
import {
modifyRepo,
isModifyRepoPending,
getModifyRepoFailure
} from "../modules/repos";
import { withRouter } from "react-router-dom";
import type { History } from "history";
import ErrorNotification from "../../components/ErrorNotification";
type Props = {
repository: Repository,
modifyRepo: (Repository, () => void) => void,
loading: boolean,
error: Error,
// context props
t: string => string,
history: History
};
class Edit extends React.Component<Props> {
repoModified = () => {
const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.name}`);
};
render() {
const { loading, error } = this.props;
return (
<div>
<ErrorNotification error={error} />
<RepositoryForm
repository={this.props.repository}
loading={loading}
submitForm={repo => {
this.props.modifyRepo(repo, this.repoModified);
}}
/>
</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));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(withRouter(Edit)));

View File

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

View File

@@ -0,0 +1,147 @@
//@flow
import React from "react";
import {
deleteRepo,
fetchRepo,
getFetchRepoFailure,
getRepository,
isFetchRepoPending
} from "../modules/repos";
import { connect } from "react-redux";
import { Route } from "react-router-dom";
import type { Repository } from "../types/Repositories";
import { Page } from "../../components/layout";
import Loading from "../../components/Loading";
import ErrorPage from "../../components/ErrorPage";
import { translate } from "react-i18next";
import { Navigation, NavLink, Section } from "../../components/navigation";
import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction";
import Edit from "../containers/Edit";
import type { History } from "history";
import EditNavLink from "../components/EditNavLink";
type Props = {
namespace: string,
name: string,
repository: Repository,
loading: boolean,
error: Error,
// dispatch functions
fetchRepo: (namespace: string, name: string) => void,
deleteRepo: (repository: Repository, () => void) => void,
// context props
t: string => string,
history: History,
match: any
};
class RepositoryRoot extends React.Component<Props> {
componentDidMount() {
const { fetchRepo, namespace, name } = this.props;
fetchRepo(namespace, name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
deleted = () => {
this.props.history.push("/repos");
};
delete = (repository: Repository) => {
this.props.deleteRepo(repository, this.deleted);
};
render() {
const { loading, error, repository, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("repository-root.error-title")}
subtitle={t("repository-root.error-subtitle")}
error={error}
/>
);
}
if (!repository || loading) {
return <Loading />;
}
const url = this.matchedUrl();
return (
<Page title={repository.namespace + "/" + repository.name}>
<div className="columns">
<div className="column is-three-quarters">
<Route
path={url}
exact
component={() => <RepositoryDetails repository={repository} />}
/>
<Route
path={`${url}/edit`}
component={() => <Edit repository={repository} />}
/>
</div>
<div className="column">
<Navigation>
<Section label={t("repository-root.navigation-label")}>
<NavLink to={url} label={t("repository-root.information")} />
<EditNavLink repository={repository} editUrl={`${url}/edit`} />
</Section>
<Section label={t("repository-root.actions-label")}>
<DeleteNavAction repository={repository} delete={this.delete} />
<NavLink to="/repos" label={t("repository-root.back-label")} />
</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);
return {
namespace,
name,
repository,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRepo: (namespace: string, name: string) => {
dispatch(fetchRepo(namespace, name));
},
deleteRepo: (repository: Repository, callback: () => void) => {
dispatch(deleteRepo(repository, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(RepositoryRoot));

View File

@@ -0,0 +1,447 @@
// @flow
import { apiClient } from "../../apiclient";
import * as types from "../../modules/types";
import type { Action } from "../../types/Action";
import type { Repository, RepositoryCollection } from "../types/Repositories";
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 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 REPOS_URL = "repositories";
const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
// fetch repos
const SORT_BY = "sortBy=namespaceAndName";
export function fetchRepos() {
return fetchReposByLink(REPOS_URL);
}
export function fetchReposByPage(page: number) {
return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`);
}
function appendSortByLink(url: string) {
if (url.includes(SORT_BY)) {
return url;
}
let urlWithSortBy = url;
if (url.includes("?")) {
urlWithSortBy += "&";
} else {
urlWithSortBy += "?";
}
return urlWithSortBy + SORT_BY;
}
export function fetchReposByLink(link: string) {
const url = appendSortByLink(link);
return function(dispatch: any) {
dispatch(fetchReposPending());
return apiClient
.get(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 fetchRepo(namespace: string, name: string) {
return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name));
return apiClient
.get(`${REPOS_URL}/${namespace}/${name}`)
.then(response => response.json())
.then(repository => {
dispatch(fetchRepoSuccess(repository));
})
.catch(err => {
dispatch(fetchRepoFailure(namespace, name, err));
});
};
}
export function fetchRepoPending(namespace: string, name: string): Action {
return {
type: FETCH_REPO_PENDING,
payload: {
namespace,
name
},
itemId: namespace + "/" + name
};
}
export function fetchRepoSuccess(repository: Repository): Action {
return {
type: FETCH_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function fetchRepoFailure(
namespace: string,
name: string,
error: Error
): Action {
return {
type: FETCH_REPO_FAILURE,
payload: {
namespace,
name,
error
},
itemId: namespace + "/" + name
};
}
// create repo
export function createRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(createRepoPending());
return apiClient
.post(REPOS_URL, repository, CONTENT_TYPE)
.then(() => {
dispatch(createRepoSuccess());
if (callback) {
callback();
}
})
.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();
}
})
.catch(cause => {
const error = new Error(`failed to modify repo: ${cause.message}`);
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)
};
}
// 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);
const newState = {
...state,
byNames: {
...state.byNames,
[identifier]: repository
}
};
return newState;
};
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_REPOS_SUCCESS:
return normalizeByNamespaceAndName(action.payload);
case MODIFY_REPO_SUCCESS:
return reducerByNames(state, 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);
}

View File

@@ -0,0 +1,795 @@
// @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,
fetchRepo,
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,
modifyRepoSuccess
} from "./repos";
import type { Repository, RepositoryCollection } from "../types/Repositories";
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/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
},
delete: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
},
update: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
},
permissions: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/"
},
tags: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/tags/"
},
branches: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/branches/"
},
changesets: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/changesets/"
},
sources: {
href:
"http://localhost:8081/scm/api/rest/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",
archived: false,
type: "git",
_links: {
self: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
},
delete: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
},
update: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
},
permissions: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/permissions/"
},
tags: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/tags/"
},
branches: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/branches/"
},
changesets: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/changesets/"
},
sources: {
href:
"http://localhost:8081/scm/api/rest/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/scm/api/rest/v2/repositories/slarti/fjords"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
},
update: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
},
permissions: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/permissions/"
},
tags: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/tags/"
},
branches: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/branches/"
},
changesets: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/changesets/"
},
sources: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/sources/"
}
}
};
const repositoryCollection: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
first: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
last: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/"
}
},
_embedded: {
repositories: [hitchhikerPuzzle42, hitchhikerRestatend, slartiFjords]
}
};
const repositoryCollectionWithNames: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
first: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
last: {
href:
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/"
}
},
_embedded: {
repositories: [
"hitchhiker/puzzle42",
"hitchhiker/restatend",
"slarti/fjords"
]
}
};
describe("repos fetch", () => {
const REPOS_URL = "/scm/api/rest/v2/repositories";
const SORT = "sortBy=namespaceAndName";
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
const mockStore = configureMockStore([thunk]);
afterEach(() => {
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()).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(43)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch repos from link", () => {
fetchMock.getOnce(
REPOS_URL + "?" + SORT + "&page=42",
repositoryCollection
);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store
.dispatch(
fetchReposByLink("/repositories?sortBy=namespaceAndName&page=42")
)
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should append sortby parameter and successfully fetch repos from link", () => {
fetchMock.getOnce(
"/scm/api/rest/v2/repositories?one=1&sortBy=namespaceAndName",
repositoryCollection
);
const expectedActions = [
{ type: FETCH_REPOS_PENDING },
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByLink("/repositories?one=1")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => {
fetchMock.getOnce(REPOS_URL_WITH_SORT, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepos()).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", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: FETCH_REPO_PENDING,
payload: {
namespace: "slarti",
name: "fjords"
},
itemId: "slarti/fjords"
},
{
type: FETCH_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
expect(actions[1].payload.namespace).toBe("slarti");
expect(actions[1].payload.name).toBe("fjords");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("slarti/fjords");
});
});
it("should successfully create repo slarti/fjords", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201
});
const expectedActions = [
{
type: CREATE_REPO_PENDING
},
{
type: CREATE_REPO_SUCCESS
}
];
const store = mockStore({});
return store.dispatch(createRepo(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully create repo slarti/fjords and call the callback", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201
});
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(createRepo(slartiFjords, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("should disapatch failure if server returns status code 500", () => {
fetchMock.postOnce(REPOS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createRepo(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/scm/api/rest/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/scm/api/rest/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/scm/api/rest/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
});
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);
});
});
it("should successfully modify slarti/fjords repo and call the callback", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204
});
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(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);
});
it("should update reposByNames", () => {
const oldState = {
byNames: {
"slarti/fjords": slartiFjords
}
};
let slartiFjordsEdited = { ...slartiFjords };
slartiFjordsEdited.description = "I bless the rains down in Africa";
const newState = reducer(oldState, modifyRepoSuccess(slartiFjordsEdited));
expect(newState.byNames["slarti/fjords"]).toEqual(slartiFjordsEdited);
});
});
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 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,110 @@
// @flow
import * as types from "../../modules/types";
import type { Action } from "../../types/Action";
import type {
RepositoryType,
RepositoryTypeCollection
} from "../types/RepositoryTypes";
import { apiClient } from "../../apiclient";
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(err => {
const error = new Error(
`failed to fetch repository types: ${err.message}`
);
dispatch(fetchRepositoryTypesFailure(error));
});
}
export function shouldFetchRepositoryTypes(state: Object) {
if (
isFetchRepositoryTypesPending(state) ||
getFetchRepositoryTypesFailure(state)
) {
return false;
}
if (state.repositoryTypes && state.repositoryTypes.length > 0) {
return false;
}
return true;
}
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/scm/api/rest/v2/repositoryTypes/git"
}
}
};
const hg = {
name: "hg",
displayName: "Mercurial",
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/hg"
}
}
};
const svn = {
name: "svn",
displayName: "Subversion",
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/svn"
}
}
};
const collection = {
_embedded: {
repositoryTypes: [git, hg, svn]
},
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/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 = "/scm/api/rest/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,25 @@
//@flow
import type { Links } from "../../types/hal";
import type { PagedCollection } from "../../types/Collection";
export type Repository = {
namespace: string,
name: string,
type: string,
contact?: string,
description?: string,
creationDate?: string,
lastModified?: string,
_links: Links
};
export type RepositoryCollection = PagedCollection & {
_embedded: {
repositories: Repository[] | string[]
}
};
export type RepositoryGroup = {
name: string,
repositories: Repository[]
};

View File

@@ -0,0 +1,14 @@
// @flow
import type { Collection } from "../../types/Collection";
export type RepositoryType = {
name: string,
displayName: string
};
export type RepositoryTypeCollection = Collection & {
_embedded: {
"repositoryTypes": RepositoryType[]
}
};

View File

@@ -1,24 +0,0 @@
// @flow
import React from "react";
import { Page } from "../../components/layout";
import { translate } from "react-i18next";
type Props = {
t: string => string
};
class Repositories extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<Page
title={t("repositories.title")}
subtitle={t("repositories.subtitle")}
>
{t("repositories.body")}
</Page>
);
}
}
export default translate("repositories")(Repositories);

View File

@@ -4,7 +4,8 @@ import { translate } from "react-i18next";
import type { User } from "../types/User";
import { Checkbox, InputField } from "../../components/forms";
import { SubmitButton } from "../../components/buttons";
import * as validator from "./userValidation";
import * as validator from "../../components/validation";
import * as userValidator from "./userValidation";
type Props = {
submitForm: User => void,
@@ -157,7 +158,9 @@ class UserForm extends React.Component<Props, State> {
handleDisplayNameChange = (displayName: string) => {
this.setState({
displayNameValidationError: !validator.isDisplayNameValid(displayName),
displayNameValidationError: !userValidator.isDisplayNameValid(
displayName
),
user: { ...this.state.user, displayName }
});
};
@@ -175,7 +178,7 @@ class UserForm extends React.Component<Props, State> {
this.state.validatePassword
);
this.setState({
validatePasswordError: !validator.isPasswordValid(password),
validatePasswordError: !userValidator.isPasswordValid(password),
passwordValidationError: validatePasswordError,
user: { ...this.state.user, password }
});

View File

@@ -1,30 +1,20 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import { AddButton } from "../../../components/buttons";
import classNames from "classnames";
const styles = {
spacing: {
margin: "1em 0 0 1em"
}
};
import { CreateButton } from "../../../components/buttons";
// TODO remove
type Props = {
t: string => string,
classes: any
t: string => string
};
class CreateUserButton extends React.Component<Props> {
render() {
const { classes, t } = this.props;
const { t } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<AddButton label={t("create-user-button.label")} link="/users/add" />
</div>
<CreateButton label={t("create-user-button.label")} link="/users/add" />
);
}
}
export default translate("users")(injectSheet(styles)(CreateUserButton));
export default translate("users")(CreateUserButton);

View File

@@ -3,6 +3,8 @@ import React from "react";
import type { User } from "../../types/User";
import { translate } from "react-i18next";
import { Checkbox } from "../../../components/forms";
import MailLink from "../../../components/MailLink";
import DateFromNow from "../../../components/DateFromNow";
type Props = {
user: User,
@@ -25,7 +27,9 @@ class Details extends React.Component<Props> {
</tr>
<tr>
<td>{t("user.mail")}</td>
<td>{user.mail}</td>
<td>
<MailLink address={user.mail} />
</td>
</tr>
<tr>
<td>{t("user.admin")}</td>
@@ -39,6 +43,22 @@ class Details extends React.Component<Props> {
<Checkbox checked={user.active} />
</td>
</tr>
<tr>
<td>{t("user.type")}</td>
<td>{user.type}</td>
</tr>
<tr>
<td>{t("user.creationDate")}</td>
<td>
<DateFromNow date={user.creationDate} />
</td>
</tr>
<tr>
<td>{t("user.lastModified")}</td>
<td>
<DateFromNow date={user.lastModified} />
</td>
</tr>
</tbody>
</table>
);

View File

@@ -1,24 +1,11 @@
// @flow
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
export const isDisplayNameValid = (displayName: string) => {
if (displayName) {
return true;
}
return false;
};
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
export const isMailValid = (mail: string) => {
return mailRegex.test(mail);
};
export const isPasswordValid = (password: string) => {
return password.length > 6 && password.length < 32;
};

View File

@@ -1,45 +1,6 @@
// @flow
import * as validator from "./userValidation";
describe("test name validation", () => {
it("should return false", () => {
// invalid names taken from ValidationUtilTest.java
const invalidNames = [
" test 123",
" test 123 ",
"test 123 ",
"test/123",
"test%123",
"test:123",
"t ",
" t",
" t ",
""
];
for (let name of invalidNames) {
expect(validator.isNameValid(name)).toBe(false);
}
});
it("should return true", () => {
// valid names taken from ValidationUtilTest.java
const validNames = [
"test",
"test.git",
"Test123.git",
"Test123-git",
"Test_user-123.git",
"test@scm-manager.de",
"test 123",
"tt",
"t"
];
for (let name of validNames) {
expect(validator.isNameValid(name)).toBe(true);
}
});
});
describe("test displayName validation", () => {
it("should return false", () => {
expect(validator.isDisplayNameValid("")).toBe(false);
@@ -60,41 +21,6 @@ describe("test displayName validation", () => {
});
});
describe("test mail validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java
const invalid = [
"ostfalia.de",
"@ostfalia.de",
"s.sdorra@",
"s.sdorra@ostfalia",
"s.sdorra@@ostfalia.de",
"s.sdorra@ ostfalia.de",
"s.sdorra @ostfalia.de"
];
for (let mail of invalid) {
expect(validator.isMailValid(mail)).toBe(false);
}
});
it("should return true", () => {
// valid taken from ValidationUtilTest.java
const valid = [
"s.sdorra@ostfalia.de",
"sdorra@ostfalia.de",
"s.sdorra@hbk-bs.de",
"s.sdorra@gmail.com",
"s.sdorra@t.co",
"s.sdorra@ucla.college",
"s.sdorra@example.xn--p1ai",
"s.sdorra@scm.solutions"
];
for (let mail of valid) {
expect(validator.isMailValid(mail)).toBe(true);
}
});
});
describe("test password validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java

View File

@@ -48,6 +48,7 @@ class AddUser extends React.Component<Props> {
title={t("add-user.title")}
subtitle={t("add-user.subtitle")}
error={error}
showContentOnError={true}
>
<UserForm
submitForm={user => this.createUser(user)}

View File

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

View File

@@ -143,7 +143,7 @@ export function createUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(createUserPending(user));
return apiClient
.postWithContentType(USERS_URL, user, CONTENT_TYPE_USER)
.post(USERS_URL, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(createUserSuccess());
if (callback) {
@@ -192,7 +192,7 @@ export function modifyUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyUserPending(user));
return apiClient
.putWithContentType(user._links.update.href, user, CONTENT_TYPE_USER)
.put(user._links.update.href, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(modifyUserSuccess(user));
if (callback) {

View File

@@ -8,5 +8,8 @@ export type User = {
password: string,
admin: boolean,
active: boolean,
type?: string,
creationDate?: string,
lastModified?: string,
_links: Links
};

View File

@@ -3104,6 +3104,10 @@ follow-redirects@^1.0.0:
dependencies:
debug "^3.1.0"
font-awesome@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
for-in@^1.0.1, for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -5216,6 +5220,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi
dependencies:
minimist "0.0.8"
moment@^2.22.2:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"

View File

@@ -2,14 +2,13 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import org.mapstruct.Mapping;
import sonia.scm.ModelObject;
import java.time.Instant;
abstract class BaseMapper<T extends ModelObject, D extends HalRepresentation> {
abstract class BaseMapper<T, D extends HalRepresentation> {
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
public abstract D map(T modelObject);
public abstract D map(T object);
Instant mapTime(Long epochMilli) {
return epochMilli == null? null: Instant.ofEpochMilli(epochMilli);

View File

@@ -0,0 +1,32 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Links.linkingTo;
abstract class CollectionToDtoMapper<E, D extends HalRepresentation> {
private final String collectionName;
private final BaseMapper<E, D> mapper;
protected CollectionToDtoMapper(String collectionName, BaseMapper<E, D> mapper) {
this.collectionName = collectionName;
this.mapper = mapper;
}
public HalRepresentation map(Collection<E> collection) {
List<D> dtos = collection.stream().map(mapper::map).collect(Collectors.toList());
return new HalRepresentation(
linkingTo().self(createSelfLink()).build(),
embeddedBuilder().with(collectionName, dtos).build()
);
}
protected abstract String createSelfLink();
}

View File

@@ -21,6 +21,9 @@ public class MapperModule extends AbstractModule {
bind(RepositoryToRepositoryDtoMapper.class).to(Mappers.getMapper(RepositoryToRepositoryDtoMapper.class).getClass());
bind(RepositoryDtoToRepositoryMapper.class).to(Mappers.getMapper(RepositoryDtoToRepositoryMapper.class).getClass());
bind(RepositoryTypeToRepositoryTypeDtoMapper.class).to(Mappers.getMapper(RepositoryTypeToRepositoryTypeDtoMapper.class).getClass());
bind(RepositoryTypeCollectionToDtoMapper.class);
bind(UriInfoStore.class).in(ServletScopes.REQUEST);
}
}

View File

@@ -0,0 +1,36 @@
package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import de.otto.edison.hal.HalRepresentation;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
public class RepositoryTypeCollectionResource {
private RepositoryManager repositoryManager;
private RepositoryTypeCollectionToDtoMapper mapper;
@Inject
public RepositoryTypeCollectionResource(RepositoryManager repositoryManager, RepositoryTypeCollectionToDtoMapper mapper) {
this.repositoryManager = repositoryManager;
this.mapper = mapper;
}
@GET
@Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.REPOSITORY_TYPE_COLLECTION)
public HalRepresentation getAll() {
return mapper.map(repositoryManager.getConfiguredTypes());
}
}

View File

@@ -0,0 +1,21 @@
package sonia.scm.api.v2.resources;
import sonia.scm.repository.RepositoryType;
import javax.inject.Inject;
public class RepositoryTypeCollectionToDtoMapper extends CollectionToDtoMapper<RepositoryType, RepositoryTypeDto> {
private final ResourceLinks resourceLinks;
@Inject
public RepositoryTypeCollectionToDtoMapper(RepositoryTypeToRepositoryTypeDtoMapper mapper, ResourceLinks resourceLinks) {
super("repositoryTypes", mapper);
this.resourceLinks = resourceLinks;
}
@Override
protected String createSelfLink() {
return resourceLinks.repositoryTypeCollection().self();
}
}

View File

@@ -0,0 +1,22 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@Getter
@Setter
public class RepositoryTypeDto extends HalRepresentation {
private String name;
private String displayName;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) {
return super.add(links);
}
}

View File

@@ -0,0 +1,53 @@
package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Response;
public class RepositoryTypeResource {
private RepositoryManager repositoryManager;
private RepositoryTypeToRepositoryTypeDtoMapper mapper;
@Inject
public RepositoryTypeResource(RepositoryManager repositoryManager, RepositoryTypeToRepositoryTypeDtoMapper mapper) {
this.repositoryManager = repositoryManager;
this.mapper = mapper;
}
/**
* Returns the specified repository type.
*
* <strong>Note:</strong> This method requires "group" privilege.
*
* @param name of the requested repository type
*/
@GET
@Path("")
@Produces(VndMediaType.REPOSITORY_TYPE)
@TypeHint(RepositoryTypeDto.class)
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 404, condition = "not found, no repository type with the specified name available"),
@ResponseCode(code = 500, condition = "internal server error")
})
public Response get(@PathParam("name") String name) {
for (RepositoryType type : repositoryManager.getConfiguredTypes()) {
if (name.equalsIgnoreCase(type.getName())) {
return Response.ok(mapper.map(type)).build();
}
}
return Response.status(Response.Status.NOT_FOUND).build();
}
}

View File

@@ -0,0 +1,35 @@
package sonia.scm.api.v2.resources;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.ws.rs.Path;
/**
* RESTful Web Service Resource to get available repository types.
*/
@Path(RepositoryTypeRootResource.PATH)
public class RepositoryTypeRootResource {
static final String PATH = "v2/repositoryTypes/";
private Provider<RepositoryTypeCollectionResource> collectionResourceProvider;
private Provider<RepositoryTypeResource> resourceProvider;
@Inject
public RepositoryTypeRootResource(Provider<RepositoryTypeCollectionResource> collectionResourceProvider, Provider<RepositoryTypeResource> resourceProvider) {
this.collectionResourceProvider = collectionResourceProvider;
this.resourceProvider = resourceProvider;
}
@Path("")
public RepositoryTypeCollectionResource getRepositoryTypeCollectionResource() {
return collectionResourceProvider.get();
}
@Path("{name}")
public RepositoryTypeResource getRepositoryTypeResource() {
return resourceProvider.get();
}
}

View File

@@ -0,0 +1,25 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import sonia.scm.repository.RepositoryType;
import javax.inject.Inject;
import static de.otto.edison.hal.Links.linkingTo;
@Mapper
public abstract class RepositoryTypeToRepositoryTypeDtoMapper extends BaseMapper<RepositoryType, RepositoryTypeDto> {
@Inject
private ResourceLinks resourceLinks;
@AfterMapping
void appendLinks(RepositoryType repositoryType, @MappingTarget RepositoryTypeDto target) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryType().self(repositoryType.getName()));
target.add(linksBuilder.build());
}
}

View File

@@ -172,6 +172,39 @@ class ResourceLinks {
}
}
public RepositoryTypeLinks repositoryType() {
return new RepositoryTypeLinks(uriInfoStore.get());
}
static class RepositoryTypeLinks {
private final LinkBuilder repositoryTypeLinkBuilder;
RepositoryTypeLinks(UriInfo uriInfo) {
repositoryTypeLinkBuilder = new LinkBuilder(uriInfo, RepositoryTypeRootResource.class, RepositoryTypeResource.class);
}
String self(String name) {
return repositoryTypeLinkBuilder.method("getRepositoryTypeResource").parameters(name).method("get").parameters().href();
}
}
public RepositoryTypeCollectionLinks repositoryTypeCollection() {
return new RepositoryTypeCollectionLinks(uriInfoStore.get());
}
static class RepositoryTypeCollectionLinks {
private final LinkBuilder collectionLinkBuilder;
RepositoryTypeCollectionLinks(UriInfo uriInfo) {
collectionLinkBuilder = new LinkBuilder(uriInfo, RepositoryTypeRootResource.class, RepositoryTypeCollectionResource.class);
}
String self() {
return collectionLinkBuilder.method("getRepositoryTypeCollectionResource").parameters().method("getAll").parameters().href();
}
}
public TagCollectionLinks tagCollection() {
return new TagCollectionLinks(uriInfoStore.get());
}

View File

@@ -1,17 +1,24 @@
package sonia.scm.repository;
import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils;
import sonia.scm.plugin.Extension;
/**
* The DefaultNamespaceStrategy returns the username of the currently logged in user as namespace.
* The DefaultNamespaceStrategy returns the predefined namespace of the given repository, if the namespace was not set
* the username of the currently loggedin user is used.
*
* @since 2.0.0
*/
@Extension
public class DefaultNamespaceStrategy implements NamespaceStrategy {
@Override
public String getNamespace() {
return SecurityUtils.getSubject().getPrincipal().toString();
public String createNamespace(Repository repository) {
String namespace = repository.getNamespace();
if (Strings.isNullOrEmpty(namespace)) {
namespace = SecurityUtils.getSubject().getPrincipal().toString();
}
return namespace;
}
}

View File

@@ -141,7 +141,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
public Repository create(Repository repository, boolean initRepository) throws RepositoryException {
repository.setId(keyGenerator.createKey());
repository.setNamespace(namespaceStrategy.getNamespace());
repository.setNamespace(namespaceStrategy.createNamespace(repository));
logger.info("create repository {} of type {} in namespace {}", repository.getName(), repository.getType(), repository.getNamespace());
@@ -301,8 +301,8 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
}
@Override
public Collection<Type> getConfiguredTypes() {
List<Type> validTypes = Lists.newArrayList();
public Collection<RepositoryType> getConfiguredTypes() {
List<RepositoryType> validTypes = Lists.newArrayList();
for (RepositoryHandler handler : handlerMap.values()) {
if (handler.isConfigured()) {

View File

@@ -0,0 +1,60 @@
package sonia.scm.api.v2.resources;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.runners.MockitoJUnitRunner;
import sonia.scm.repository.RepositoryType;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.*;
@RunWith(MockitoJUnitRunner.class)
public class RepositoryTypeCollectionToDtoMapperTest {
private final URI baseUri = URI.create("https://scm-manager.org/scm/");
@SuppressWarnings("unused") // Is injected
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@InjectMocks
private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
private List<RepositoryType> types = Lists.newArrayList(
new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()),
new RepositoryType("hog", "Heart of Gold", Sets.newHashSet())
);
private RepositoryTypeCollectionToDtoMapper collectionMapper;
@Before
public void setUpEnvironment() {
collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks);
}
@Test
public void shouldHaveEmbeddedDtos() {
HalRepresentation mappedTypes = collectionMapper.map(types);
Embedded embedded = mappedTypes.getEmbedded();
List<RepositoryTypeDto> embeddedTypes = embedded.getItemsBy("repositoryTypes", RepositoryTypeDto.class);
assertEquals("hk", embeddedTypes.get(0).getName());
assertEquals("hog", embeddedTypes.get(1).getName());
}
@Test
public void shouldHaveSelfLink() {
HalRepresentation mappedTypes = collectionMapper.map(types);
Optional<Link> self = mappedTypes.getLinks().getLinkBy("self");
assertEquals("https://scm-manager.org/scm/v2/repositoryTypes/", self.get().getHref());
}
}

View File

@@ -0,0 +1,142 @@
package sonia.scm.api.v2.resources;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryType;
import sonia.scm.web.VndMediaType;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static org.junit.Assert.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class RepositoryTypeRootResourceTest {
private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
@Mock
private RepositoryManager repositoryManager;
private final URI baseUri = URI.create("/");
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@InjectMocks
private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
private List<RepositoryType> types = Lists.newArrayList(
new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()),
new RepositoryType("hog", "Heart of Gold", Sets.newHashSet())
);
@Before
public void prepareEnvironment() {
when(repositoryManager.getConfiguredTypes()).thenReturn(types);
RepositoryTypeCollectionToDtoMapper collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks);
RepositoryTypeCollectionResource collectionResource = new RepositoryTypeCollectionResource(repositoryManager, collectionMapper);
RepositoryTypeResource resource = new RepositoryTypeResource(repositoryManager, mapper);
RepositoryTypeRootResource rootResource = new RepositoryTypeRootResource(MockProvider.of(collectionResource), MockProvider.of(resource));
dispatcher.getRegistry().addSingletonResource(rootResource);
}
@Test
public void shouldHaveCollectionVndMediaType() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
String contentType = response.getOutputHeaders().getFirst("Content-Type").toString();
assertThat(VndMediaType.REPOSITORY_TYPE_COLLECTION, equalToIgnoringCase(contentType));
}
@Test
public void shouldHaveCollectionSelfLink() throws URISyntaxException {
String uri = "/" + RepositoryTypeRootResource.PATH;
MockHttpRequest request = MockHttpRequest.get(uri);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}"));
}
@Test
public void shouldHaveEmbeddedRepositoryTypes() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("Hitchhiker"));
assertTrue(response.getContentAsString().contains("Heart of Gold"));
}
@Test
public void shouldHaveVndMediaType() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "hk");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
String contentType = response.getOutputHeaders().getFirst("Content-Type").toString();
assertThat(VndMediaType.REPOSITORY_TYPE, equalToIgnoringCase(contentType));
}
@Test
public void shouldContainAttributes() throws URISyntaxException {
String uri = "/" + RepositoryTypeRootResource.PATH + "hk";
MockHttpRequest request = MockHttpRequest.get(uri);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("hk"));
assertTrue(response.getContentAsString().contains("Hitchhiker"));
}
@Test
public void shouldHaveSelfLink() throws URISyntaxException {
String uri = "/" + RepositoryTypeRootResource.PATH + "hk";
MockHttpRequest request = MockHttpRequest.get(uri);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}"));
}
@Test
public void shouldReturn404OnUnknownTypes() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "git");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_NOT_FOUND, response.getStatus());
}
}

View File

@@ -0,0 +1,42 @@
package sonia.scm.api.v2.resources;
import com.google.common.collect.Sets;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.runners.MockitoJUnitRunner;
import sonia.scm.repository.RepositoryType;
import java.net.URI;
import static org.junit.Assert.assertEquals;
@RunWith(MockitoJUnitRunner.class)
public class RepositoryTypeToRepositoryTypeDtoMapperTest {
private final URI baseUri = URI.create("https://scm-manager.org/scm/");
@SuppressWarnings("unused") // Is injected
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@InjectMocks
private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
private RepositoryType type = new RepositoryType("hk", "Hitchhiker", Sets.newHashSet());
@Test
public void shouldMapSimpleProperties() {
RepositoryTypeDto dto = mapper.map(type);
assertEquals("hk", dto.getName());
assertEquals("Hitchhiker", dto.getDisplayName());
}
@Test
public void shouldAppendSelfLink() {
RepositoryTypeDto dto = mapper.map(type);
assertEquals(
"https://scm-manager.org/scm/v2/repositoryTypes/hk",
dto.getLinks().getLinkBy("self").get().getHref()
);
}
}

View File

@@ -25,6 +25,9 @@ public class ResourceLinksMock {
when(resourceLinks.sourceCollection()).thenReturn(new ResourceLinks.SourceCollectionLinks(uriInfo));
when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo));
when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo));
when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo));
when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo));
return resourceLinks;
}
}

View File

@@ -65,7 +65,7 @@ import java.util.Collection;
*/
public final class IntegrationTestUtil
{
public static final Person AUTHOR = new Person("SCM Administrator", "scmadmin@scm-manager.org");
/** Field description */
@@ -73,10 +73,10 @@ public final class IntegrationTestUtil
/** Field description */
public static final String ADMIN_USERNAME = "scmadmin";
/** scm-manager base url */
public static final String BASE_URL = "http://localhost:8081/scm/";
/** scm-manager base url for the rest api */
public static final String REST_BASE_URL = BASE_URL.concat("api/rest/v2/");
@@ -163,7 +163,7 @@ public final class IntegrationTestUtil
{
return URI.create(REST_BASE_URL).resolve(url);
}
public static String readJson(String jsonFileName) {
URL url = Resources.getResource(jsonFileName);
try {

View File

@@ -17,8 +17,16 @@ public class DefaultNamespaceStrategyTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void testNamespaceStrategy() {
assertEquals("trillian", namespaceStrategy.getNamespace());
public void testNamespaceStrategyWithoutPreset() {
assertEquals("trillian", namespaceStrategy.createNamespace(new Repository()));
}
@Test
@SubjectAware(username = "trillian", password = "secret")
public void testNamespaceStrategyWithPreset() {
Repository repository = new Repository();
repository.setNamespace("awesome");
assertEquals("awesome", namespaceStrategy.createNamespace(repository));
}
}

View File

@@ -32,6 +32,7 @@ package sonia.scm.repository;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.inject.Provider;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
@@ -109,7 +110,7 @@ public class DefaultRepositoryManagerPerfTest {
*/
@Before
public void setUpObjectUnderTest(){
when(repositoryHandler.getType()).thenReturn(new Type(REPOSITORY_TYPE, REPOSITORY_TYPE));
when(repositoryHandler.getType()).thenReturn(new RepositoryType(REPOSITORY_TYPE, REPOSITORY_TYPE, Sets.newHashSet()));
Set<RepositoryHandler> handlerSet = ImmutableSet.of(repositoryHandler);
RepositoryMatcher repositoryMatcher = new RepositoryMatcher(Collections.<RepositoryPathMatcher>emptySet());
NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class);

View File

@@ -36,10 +36,12 @@ import com.github.legman.Subscribe;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import org.apache.shiro.authz.UnauthorizedException;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import sonia.scm.*;
import sonia.scm.config.ScmConfiguration;
@@ -57,7 +59,14 @@ import sonia.scm.store.JAXBConfigurationStoreFactory;
import java.util.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@@ -66,7 +75,7 @@ import static org.mockito.Mockito.when;
/**
* Unit tests for {@link DefaultRepositoryManager}.
*
*
* @author Sebastian Sdorra
*/
@SubjectAware(
@@ -300,7 +309,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
manager.create(heartOfGold);
heartOfGold.setDescription("prototype ship");
thrown.expect(UnauthorizedException.class);
manager.modify(heartOfGold);
}
@@ -326,7 +335,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
manager.create(heartOfGold);
heartOfGold.setDescription("prototype ship");
thrown.expect(UnauthorizedException.class);
manager.refresh(heartOfGold);
}
@@ -449,7 +458,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
}
//~--- methods --------------------------------------------------------------
@Override
protected DefaultRepositoryManager createManager() {
return createRepositoryManager(false);
@@ -465,14 +474,14 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
handlerSet.add(new DummyRepositoryHandler(factory));
handlerSet.add(new DummyRepositoryHandler(factory) {
@Override
public Type getType() {
return new Type("hg", "Mercurial");
public RepositoryType getType() {
return new RepositoryType("hg", "Mercurial", Sets.newHashSet());
}
});
handlerSet.add(new DummyRepositoryHandler(factory) {
@Override
public Type getType() {
return new Type("git", "Git");
public RepositoryType getType() {
return new RepositoryType("git", "Git", Sets.newHashSet());
}
});
@@ -483,12 +492,12 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
configuration.setEnableRepositoryArchive(archiveEnabled);
NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class);
when(namespaceStrategy.getNamespace()).thenAnswer(invocation -> mockedNamespace);
when(namespaceStrategy.createNamespace(Mockito.any(Repository.class))).thenAnswer(invocation -> mockedNamespace);
return new DefaultRepositoryManager(configuration, contextProvider,
keyGenerator, repositoryDAO, handlerSet, createRepositoryMatcher(), namespaceStrategy);
}
private void createRepository(RepositoryManager m, Repository repository)
throws RepositoryException {
m.create(repository);
@@ -503,7 +512,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
return new HookContextFactory(ppu).createContext(provider, repository);
}
private void assertRepositoriesEquals(Repository repo, Repository other) {
assertEquals(repo.getId(), other.getId());
assertEquals(repo.getName(), other.getName());
@@ -512,10 +521,10 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository, Re
assertEquals(repo.getCreationDate(), other.getCreationDate());
assertEquals(repo.getLastModified(), other.getLastModified());
}
private RepositoryMatcher createRepositoryMatcher() {
return new RepositoryMatcher(Collections.<RepositoryPathMatcher>emptySet());
}
}
private Repository createRepository(Repository repository) throws RepositoryException {
manager.create(repository);