mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-07-01 00:38:33 +02:00
merge with branch feature/repositories-ui
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -82,4 +82,7 @@ public interface RepositoryHandler
|
||||
* @since 1.15
|
||||
*/
|
||||
public String getVersionInformation();
|
||||
|
||||
@Override
|
||||
RepositoryType getType();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -103,7 +103,7 @@ public class RepositoryManagerDecorator
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Collection<Type> getConfiguredTypes()
|
||||
public Collection<RepositoryType> getConfiguredTypes()
|
||||
{
|
||||
return decorated.getConfiguredTypes();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
46
scm-ui/public/locales/en/repos.json
Normal file
46
scm-ui/public/locales/en/repos.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"repositories": {
|
||||
"title": "Repositories",
|
||||
"subtitle": "Repositories will be shown here",
|
||||
"body": "Coming soon ..."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
32
scm-ui/src/components/DateFromNow.js
Normal file
32
scm-ui/src/components/DateFromNow.js
Normal 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);
|
||||
18
scm-ui/src/components/MailLink.js
Normal file
18
scm-ui/src/components/MailLink.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -10,7 +10,8 @@ export type ButtonProps = {
|
||||
action?: () => void,
|
||||
link?: string,
|
||||
fullWidth?: boolean,
|
||||
className?: string
|
||||
className?: string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
type Props = ButtonProps & {
|
||||
|
||||
24
scm-ui/src/components/buttons/CreateButton.js
Normal file
24
scm-ui/src/components/buttons/CreateButton.js
Normal 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);
|
||||
@@ -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";
|
||||
|
||||
67
scm-ui/src/components/forms/Select.js
Normal file
67
scm-ui/src/components/forms/Select.js
Normal 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;
|
||||
53
scm-ui/src/components/forms/Textarea.js
Normal file
53
scm-ui/src/components/forms/Textarea.js
Normal 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;
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as Checkbox } from "./Checkbox";
|
||||
export { default as InputField } from "./InputField";
|
||||
export { default as Select } from "./Select";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
12
scm-ui/src/components/validation.js
Normal file
12
scm-ui/src/components/validation.js
Normal 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);
|
||||
};
|
||||
75
scm-ui/src/components/validation.test.js
Normal file
75
scm-ui/src/components/validation.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
scm-ui/src/repos/components/DeleteNavAction.js
Normal file
59
scm-ui/src/repos/components/DeleteNavAction.js
Normal 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);
|
||||
79
scm-ui/src/repos/components/DeleteNavAction.test.js
Normal file
79
scm-ui/src/repos/components/DeleteNavAction.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
22
scm-ui/src/repos/components/EditNavLink.js
Normal file
22
scm-ui/src/repos/components/EditNavLink.js
Normal 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);
|
||||
32
scm-ui/src/repos/components/EditNavLink.test.js
Normal file
32
scm-ui/src/repos/components/EditNavLink.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
56
scm-ui/src/repos/components/RepositoryDetails.js
Normal file
56
scm-ui/src/repos/components/RepositoryDetails.js
Normal 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);
|
||||
168
scm-ui/src/repos/components/form/RepositoryForm.js
Normal file
168
scm-ui/src/repos/components/form/RepositoryForm.js
Normal 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);
|
||||
2
scm-ui/src/repos/components/form/index.js
Normal file
2
scm-ui/src/repos/components/form/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryForm from "./RepositoryForm";
|
||||
export default RepositoryForm;
|
||||
10
scm-ui/src/repos/components/form/repositoryValidation.js
Normal file
10
scm-ui/src/repos/components/form/repositoryValidation.js
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
119
scm-ui/src/repos/components/list/RepositoryEntry.js
Normal file
119
scm-ui/src/repos/components/list/RepositoryEntry.js
Normal 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);
|
||||
34
scm-ui/src/repos/components/list/RepositoryEntryLink.js
Normal file
34
scm-ui/src/repos/components/list/RepositoryEntryLink.js
Normal 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);
|
||||
67
scm-ui/src/repos/components/list/RepositoryGroupEntry.js
Normal file
67
scm-ui/src/repos/components/list/RepositoryGroupEntry.js
Normal 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);
|
||||
28
scm-ui/src/repos/components/list/RepositoryList.js
Normal file
28
scm-ui/src/repos/components/list/RepositoryList.js
Normal 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;
|
||||
39
scm-ui/src/repos/components/list/groupByNamespace.js
Normal file
39
scm-ui/src/repos/components/list/groupByNamespace.js
Normal 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;
|
||||
}
|
||||
74
scm-ui/src/repos/components/list/groupByNamespace.test.js
Normal file
74
scm-ui/src/repos/components/list/groupByNamespace.test.js
Normal 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);
|
||||
});
|
||||
2
scm-ui/src/repos/components/list/index.js
Normal file
2
scm-ui/src/repos/components/list/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryList from "./RepositoryList";
|
||||
export default RepositoryList;
|
||||
111
scm-ui/src/repos/containers/Create.js
Normal file
111
scm-ui/src/repos/containers/Create.js
Normal 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));
|
||||
71
scm-ui/src/repos/containers/Edit.js
Normal file
71
scm-ui/src/repos/containers/Edit.js
Normal 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)));
|
||||
143
scm-ui/src/repos/containers/Overview.js
Normal file
143
scm-ui/src/repos/containers/Overview.js
Normal 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)));
|
||||
147
scm-ui/src/repos/containers/RepositoryRoot.js
Normal file
147
scm-ui/src/repos/containers/RepositoryRoot.js
Normal 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));
|
||||
447
scm-ui/src/repos/modules/repos.js
Normal file
447
scm-ui/src/repos/modules/repos.js
Normal 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);
|
||||
}
|
||||
795
scm-ui/src/repos/modules/repos.test.js
Normal file
795
scm-ui/src/repos/modules/repos.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
110
scm-ui/src/repos/modules/repositoryTypes.js
Normal file
110
scm-ui/src/repos/modules/repositoryTypes.js
Normal 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);
|
||||
}
|
||||
198
scm-ui/src/repos/modules/repositoryTypes.test.js
Normal file
198
scm-ui/src/repos/modules/repositoryTypes.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
25
scm-ui/src/repos/types/Repositories.js
Normal file
25
scm-ui/src/repos/types/Repositories.js
Normal 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[]
|
||||
};
|
||||
14
scm-ui/src/repos/types/RepositoryTypes.js
Normal file
14
scm-ui/src/repos/types/RepositoryTypes.js
Normal 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[]
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,5 +8,8 @@ export type User = {
|
||||
password: string,
|
||||
admin: boolean,
|
||||
active: boolean,
|
||||
type?: string,
|
||||
creationDate?: string,
|
||||
lastModified?: string,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user