Merged 2.0.0-m3

This commit is contained in:
Philipp Czora
2018-10-19 08:44:03 +02:00
27 changed files with 1002 additions and 229 deletions

View File

@@ -9,9 +9,18 @@ type Props = {
};
class Image extends React.Component<Props> {
createImageSrc = () => {
const { src } = this.props;
if (src.startsWith("http")) {
return src;
}
return withContextPath(src);
};
render() {
const { src, alt, className } = this.props;
return <img className={className} src={withContextPath(src)} alt={alt} />;
const { alt, className } = this.props;
return <img className={className} src={this.createImageSrc()} alt={alt} />;
}
}

View File

@@ -1,6 +1,6 @@
//@flow
import * as React from "react";
import { Route, Link } from "react-router-dom";
import {Link, Route} from "react-router-dom";
// TODO mostly copy of PrimaryNavigationLink

View File

@@ -5,6 +5,21 @@ export function withContextPath(path: string) {
return contextPath + path;
}
export function withEndingSlash(url: string) {
if (url.endsWith("/")) {
return url;
}
return url + "/";
}
export function concat(base: string, ...parts: string[]) {
let url = base;
for ( let p of parts) {
url = withEndingSlash(url) + p;
}
return url;
}
export function getPageFromMatch(match: any) {
let page = parseInt(match.params.page, 10);
if (isNaN(page) || !page) {

View File

@@ -1,5 +1,27 @@
// @flow
import { getPageFromMatch } from "./urls";
import {concat, getPageFromMatch, withEndingSlash} from "./urls";
describe("tests for withEndingSlash", () => {
it("should append missing slash", () => {
expect(withEndingSlash("abc")).toBe("abc/");
});
it("should not append a second slash", () => {
expect(withEndingSlash("abc/")).toBe("abc/");
});
});
describe("concat tests", () => {
it("should concat the parts to a single url", () => {
expect(concat("a")).toBe("a");
expect(concat("a", "b")).toBe("a/b");
expect(concat("a", "b", "c")).toBe("a/b/c");
});
});
describe("tests for getPageFromMatch", () => {
function createMatch(page: string) {

View File

@@ -1,14 +1,11 @@
//@flow
import type {Links} from "./hal";
export type Permission = {
name: string,
type: string,
groupPermission: boolean,
_links?: Links
export type Permission = PermissionCreateEntry & {
_links: Links
};
export type PermissionEntry = {
export type PermissionCreateEntry = {
name: string,
type: string,
groupPermission: boolean

View File

@@ -17,4 +17,4 @@ export type { Tag } from "./Tags";
export type { Config } from "./Config";
export type { Permission, PermissionEntry, PermissionCollection } from "./RepositoryPermissions";
export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions";

View File

@@ -53,7 +53,7 @@
"description": "Description",
"contact": "Contact",
"date": "Date",
"summary": "Changeset {{id}} committed {{time}}"
"summary": "Changeset {{id}} was committed {{time}}"
},
"author": {
"name": "Author",

View File

@@ -0,0 +1,32 @@
//@flow
import React from "react";
import {binder} from "@scm-manager/ui-extensions";
import type {Changeset} from "@scm-manager/ui-types";
import {Image} from "@scm-manager/ui-components";
type Props = {
changeset: Changeset
};
class AvatarImage extends React.Component<Props> {
render() {
const { changeset } = this.props;
const avatarFactory = binder.getExtension("changeset.avatar-factory");
if (avatarFactory) {
const avatar = avatarFactory(changeset);
return (
<Image
className="has-rounded-border"
src={avatar}
alt={changeset.author.name}
/>
);
}
return null;
}
}
export default AvatarImage;

View File

@@ -0,0 +1,18 @@
//@flow
import * as React from "react";
import {binder} from "@scm-manager/ui-extensions";
type Props = {
children: React.Node
};
class AvatarWrapper extends React.Component<Props> {
render() {
if (binder.hasExtension("changeset.avatar-factory")) {
return <>{this.props.children}</>;
}
return null;
}
}
export default AvatarWrapper;

View File

@@ -1,30 +0,0 @@
//@flow
import React from "react";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Changeset } from "@scm-manager/ui-types";
type Props = {
changeset: Changeset
};
class ChangesetAvatar extends React.Component<Props> {
render() {
const { changeset } = this.props;
return (
<ExtensionPoint
name="repos.changeset-table.information"
renderAll={true}
props={{ changeset }}
>
{/* extension should render something like this: */}
{/* <div className="image is-64x64"> */}
{/* <figure className="media-left"> */}
{/* <Image src="/some/image.jpg" alt="Logo" /> */}
{/* </figure> */}
{/* </div> */}
</ExtensionPoint>
);
}
}
export default ChangesetAvatar;

View File

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

View File

@@ -2,24 +2,46 @@
import {Link} from "react-router-dom";
import React from "react";
import type { Repository, Changeset } from "@scm-manager/ui-types";
import type {Changeset, Repository} from "@scm-manager/ui-types";
type Props = {
repository: Repository,
changeset: Changeset
changeset: Changeset,
link: boolean
};
export default class ChangesetId extends React.Component<Props> {
render() {
const { repository, changeset } = this.props;
static defaultProps = {
link: true
};
shortId = (changeset: Changeset) => {
return changeset.id.substr(0, 7);
};
renderLink = () => {
const { changeset, repository } = this.props;
return (
<Link
to={`/repo/${repository.namespace}/${repository.name}/changeset/${
changeset.id
}`}
>
{changeset.id.substr(0, 7)}
{this.shortId(changeset)}
</Link>
);
};
renderText = () => {
const { changeset } = this.props;
return this.shortId(changeset);
};
render() {
const { link } = this.props;
if (link) {
return this.renderLink();
}
return this.renderText();
}
}

View File

@@ -2,14 +2,16 @@
import React from "react";
import type {Changeset, Repository, Tag} from "@scm-manager/ui-types";
import classNames from "classnames";
import { translate, Interpolate } from "react-i18next";
import ChangesetAvatar from "./ChangesetAvatar";
import {Interpolate, translate} from "react-i18next";
import ChangesetId from "./ChangesetId";
import injectSheet from "react-jss";
import {DateFromNow} from "@scm-manager/ui-components";
import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTag from "./ChangesetTag";
import {compose} from "redux";
import {parseDescription} from "./changesets";
import AvatarWrapper from "./AvatarWrapper";
import AvatarImage from "./AvatarImage";
const styles = {
pointer: {
@@ -46,14 +48,23 @@ class ChangesetRow extends React.Component<Props> {
const changesetLink = this.createLink(changeset);
const dateFromNow = <DateFromNow date={changeset.date} />;
const authorLine = <ChangesetAuthor changeset={changeset} />;
const description = parseDescription(changeset.description);
return (
<article className={classNames("media", classes.inner)}>
<ChangesetAvatar changeset={changeset} />
<AvatarWrapper>
<div>
<figure className="media-left">
<p className="image is-64x64">
<AvatarImage changeset={changeset} />
</p>
</figure>
</div>
</AvatarWrapper>
<div className={classNames("media-content", classes.withOverflow)}>
<div className="content">
<p className="is-ellipsis-overflow">
{changeset.description}
<strong>{description.title}</strong>
<br />
<Interpolate
i18nKey="changesets.changeset.summary"

View File

@@ -0,0 +1,24 @@
// @flow
export type Description = {
title: string,
message: string
};
export function parseDescription(description: string): Description {
const lineBreak = description.indexOf("\n");
let title;
let message = "";
if (lineBreak > 0) {
title = description.substring(0, lineBreak);
message = description.substring(lineBreak + 1);
} else {
title = description;
}
return {
title,
message
};
}

View File

@@ -0,0 +1,16 @@
// @flow
import {parseDescription} from "./changesets";
describe("parseDescription tests", () => {
it("should return a description with title and message", () => {
const desc = parseDescription("Hello\nTrillian");
expect(desc.title).toBe("Hello");
expect(desc.message).toBe("Trillian");
});
it("should return a description with title and without message", () => {
const desc = parseDescription("Hello Trillian");
expect(desc.title).toBe("Hello Trillian");
});
});

View File

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

View File

@@ -2,12 +2,7 @@
import React from "react";
import {withRouter} from "react-router-dom";
import type {
Branch,
Changeset,
PagedCollection,
Repository
} from "@scm-manager/ui-types";
import type {Branch, Changeset, PagedCollection, Repository} from "@scm-manager/ui-types";
import {
fetchChangesets,
getChangesets,
@@ -18,12 +13,7 @@ import {
import {connect} from "react-redux";
import ChangesetList from "../components/changesets/ChangesetList";
import {
ErrorNotification,
LinkPaginator,
Loading,
getPageFromMatch
} from "@scm-manager/ui-components";
import {ErrorNotification, getPageFromMatch, LinkPaginator, Loading} from "@scm-manager/ui-components";
import {compose} from "redux";
type Props = {

View File

@@ -1,23 +1,12 @@
//@flow
import React from "react";
import {
deleteRepo,
fetchRepo,
getFetchRepoFailure,
getRepository,
isFetchRepoPending
} from "../modules/repos";
import {deleteRepo, fetchRepo, getFetchRepoFailure, getRepository, isFetchRepoPending} from "../modules/repos";
import {connect} from "react-redux";
import {Route, Switch} from "react-router-dom";
import type {Repository} from "@scm-manager/ui-types";
import {
ErrorPage,
Loading,
Navigation,
NavLink,
Page,
Section
} from "@scm-manager/ui-components";
import {ErrorPage, Loading, Navigation, NavLink, Page, Section} from "@scm-manager/ui-components";
import {translate} from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction";
@@ -26,7 +15,9 @@ import Permissions from "../permissions/containers/Permissions";
import type {History} from "history";
import EditNavLink from "../components/EditNavLink";
import BranchRoot from "./BranchRoot";
import ChangesetView from "./ChangesetView";
import PermissionsNavLink from "../components/PermissionsNavLink";
import ScmDiff from "./ScmDiff";
@@ -73,6 +64,11 @@ class RepositoryRoot extends React.Component<Props> {
this.props.deleteRepo(repository, this.deleted);
};
matchChangeset = (route: any) => {
const url = this.matchedUrl();
return route.location.pathname.match(`${url}/changeset/`);
};
matches = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`);
@@ -120,6 +116,11 @@ class RepositoryRoot extends React.Component<Props> {
/>
)}
/>
<Route
exact
path={`${url}/changeset/:id`}
render={() => <ChangesetView repository={repository} />}
/>
<Route
path={`${url}/changesets`}
render={() => (
@@ -145,7 +146,7 @@ class RepositoryRoot extends React.Component<Props> {
component={() => (
<ScmDiff
repository={repository}
revision={"2"} // TODO: this is hardcoded only for dev purposes.
revision={"a801749dc445d9d71e3fe4c50241433a2adfba6a"} // TODO: this is hardcoded only for dev purposes.
sideBySide={false}
/>
)}

View File

@@ -1,27 +1,86 @@
// @flow
import {
FAILURE_SUFFIX,
PENDING_SUFFIX,
SUCCESS_SUFFIX
} from "../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import {FAILURE_SUFFIX, PENDING_SUFFIX, SUCCESS_SUFFIX} from "../../modules/types";
import {apiClient, urls} from "@scm-manager/ui-components";
import {isPending} from "../../modules/pending";
import {getFailure} from "../../modules/failure";
import type {
Action,
Branch,
PagedCollection,
Repository
} from "@scm-manager/ui-types";
import type {Action, Branch, PagedCollection, Repository} from "@scm-manager/ui-types";
export const FETCH_CHANGESETS = "scm/repos/FETCH_CHANGESETS";
export const FETCH_CHANGESETS_PENDING = `${FETCH_CHANGESETS}_${PENDING_SUFFIX}`;
export const FETCH_CHANGESETS_SUCCESS = `${FETCH_CHANGESETS}_${SUCCESS_SUFFIX}`;
export const FETCH_CHANGESETS_FAILURE = `${FETCH_CHANGESETS}_${FAILURE_SUFFIX}`;
//TODO: Content type
export const FETCH_CHANGESET = "scm/repos/FETCH_CHANGESET";
export const FETCH_CHANGESET_PENDING = `${FETCH_CHANGESET}_${PENDING_SUFFIX}`;
export const FETCH_CHANGESET_SUCCESS = `${FETCH_CHANGESET}_${SUCCESS_SUFFIX}`;
export const FETCH_CHANGESET_FAILURE = `${FETCH_CHANGESET}_${FAILURE_SUFFIX}`;
// actions
//TODO: Content type
export function fetchChangesetIfNeeded(repository: Repository, id: string) {
return (dispatch: any, getState: any) => {
if (shouldFetchChangeset(getState(), repository, id)) {
return dispatch(fetchChangeset(repository, id));
}
};
}
export function fetchChangeset(repository: Repository, id: string) {
return function(dispatch: any) {
dispatch(fetchChangesetPending(repository, id));
return apiClient
.get(createChangesetUrl(repository, id))
.then(response => response.json())
.then(data => dispatch(fetchChangesetSuccess(data, repository, id)))
.catch(err => {
dispatch(fetchChangesetFailure(repository, id, err));
});
};
}
function createChangesetUrl(repository: Repository, id: string) {
return urls.concat(repository._links.changesets.href, id);
}
export function fetchChangesetPending(
repository: Repository,
id: string
): Action {
return {
type: FETCH_CHANGESET_PENDING,
itemId: createChangesetItemId(repository, id)
};
}
export function fetchChangesetSuccess(
changeset: any,
repository: Repository,
id: string
): Action {
return {
type: FETCH_CHANGESET_SUCCESS,
payload: { changeset, repository, id },
itemId: createChangesetItemId(repository, id)
};
}
function fetchChangesetFailure(
repository: Repository,
id: string,
error: Error
): Action {
return {
type: FETCH_CHANGESET_FAILURE,
payload: {
repository,
id,
error
},
itemId: createChangesetItemId(repository, id)
};
}
export function fetchChangesets(
repository: Repository,
@@ -80,7 +139,11 @@ export function fetchChangesetsSuccess(
): Action {
return {
type: FETCH_CHANGESETS_SUCCESS,
payload: changesets,
payload: {
repository,
branch,
changesets
},
itemId: createItemId(repository, branch)
};
}
@@ -101,6 +164,11 @@ function fetchChangesetsFailure(
};
}
function createChangesetItemId(repository: Repository, id: string) {
const { namespace, name } = repository;
return namespace + "/" + name + "/" + id;
}
function createItemId(repository: Repository, branch?: Branch): string {
const { namespace, name } = repository;
let itemId = namespace + "/" + name;
@@ -118,10 +186,32 @@ export default function reducer(
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_CHANGESET_SUCCESS:
const _key = createItemId(payload.repository);
let _oldByIds = {};
if (state[_key] && state[_key].byId) {
_oldByIds = state[_key].byId;
}
const changeset = payload.changeset;
return {
...state,
[_key]: {
...state[_key],
byId: {
..._oldByIds,
[changeset.id]: changeset
}
}
};
case FETCH_CHANGESETS_SUCCESS:
const changesets = payload._embedded.changesets;
const changesets = payload.changesets._embedded.changesets;
const changesetIds = changesets.map(c => c.id);
const key = action.itemId;
@@ -129,26 +219,32 @@ export default function reducer(
return state;
}
let oldByIds = {};
if (state[key] && state[key].byId) {
oldByIds = state[key].byId;
const repoId = createItemId(payload.repository);
let oldState = {};
if (state[repoId]) {
oldState = state[repoId];
}
const branchName = payload.branch ? payload.branch.name : "";
const byIds = extractChangesetsByIds(changesets);
return {
...state,
[key]: {
[repoId]: {
byId: {
...oldByIds,
...oldState.byId,
...byIds
},
list: {
byBranch: {
...oldState.byBranch,
[branchName]: {
entries: changesetIds,
entry: {
page: payload.page,
pageTotal: payload.pageTotal,
_links: payload._links
page: payload.changesets.page,
pageTotal: payload.changesets.pageTotal,
_links: payload.changesets._links
}
}
}
}
@@ -174,17 +270,76 @@ export function getChangesets(
repository: Repository,
branch?: Branch
) {
const key = createItemId(repository, branch);
const repoKey = createItemId(repository);
const changesets = state.changesets[key];
const stateRoot = state.changesets[repoKey];
if (!stateRoot || !stateRoot.byBranch) {
return null;
}
const branchName = branch ? branch.name : "";
const changesets = stateRoot.byBranch[branchName];
if (!changesets) {
return null;
}
return changesets.list.entries.map((id: string) => {
return changesets.byId[id];
return changesets.entries.map((id: string) => {
return stateRoot.byId[id];
});
}
export function getChangeset(
state: Object,
repository: Repository,
id: string
) {
const key = createItemId(repository);
const changesets =
state.changesets && state.changesets[key]
? state.changesets[key].byId
: null;
if (changesets != null && changesets[id]) {
return changesets[id];
}
return null;
}
export function shouldFetchChangeset(
state: Object,
repository: Repository,
id: string
) {
if (getChangeset(state, repository, id)) {
return false;
}
return true;
}
export function isFetchChangesetPending(
state: Object,
repository: Repository,
id: string
) {
return isPending(
state,
FETCH_CHANGESET,
createChangesetItemId(repository, id)
);
}
export function getFetchChangesetFailure(
state: Object,
repository: Repository,
id: string
) {
return getFailure(
state,
FETCH_CHANGESET,
createChangesetItemId(repository, id)
);
}
export function isFetchChangesetsPending(
state: Object,
repository: Repository,
@@ -202,9 +357,15 @@ export function getFetchChangesetsFailure(
}
const selectList = (state: Object, repository: Repository, branch?: Branch) => {
const itemId = createItemId(repository, branch);
if (state.changesets[itemId] && state.changesets[itemId].list) {
return state.changesets[itemId].list;
const repoId = createItemId(repository);
const branchName = branch ? branch.name : "";
if (state.changesets[repoId]) {
const repoState = state.changesets[repoId];
if (repoState.byBranch && repoState.byBranch[branchName]) {
return repoState.byBranch[branchName];
}
}
return {};
};

View File

@@ -4,15 +4,27 @@ import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_CHANGESET,
FETCH_CHANGESET_FAILURE,
FETCH_CHANGESET_PENDING,
FETCH_CHANGESET_SUCCESS,
FETCH_CHANGESETS,
FETCH_CHANGESETS_FAILURE,
FETCH_CHANGESETS_PENDING,
FETCH_CHANGESETS_SUCCESS,
fetchChangeset,
fetchChangesetIfNeeded,
fetchChangesets,
fetchChangesetsSuccess,
fetchChangesetSuccess,
getChangeset,
getChangesets,
getFetchChangesetFailure,
getFetchChangesetsFailure,
isFetchChangesetsPending
isFetchChangesetPending,
isFetchChangesetsPending,
selectListAsCollection,
shouldFetchChangeset
} from "./changesets";
const branch = {
@@ -21,7 +33,7 @@ const branch = {
_links: {
history: {
href:
"http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets"
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets"
}
}
};
@@ -32,14 +44,14 @@ const repository = {
type: "GIT",
_links: {
self: {
href: "http://scm/api/rest/v2/repositories/foo/bar"
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar"
},
changesets: {
href: "http://scm/api/rest/v2/repositories/foo/bar/changesets"
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets"
},
branches: {
href:
"http://scm/api/rest/v2/repositories/foo/bar/branches/specific/branches"
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/branches"
}
}
};
@@ -49,9 +61,10 @@ const changesets = {};
describe("changesets", () => {
describe("fetching of changesets", () => {
const DEFAULT_BRANCH_URL =
"http://scm/api/rest/v2/repositories/foo/bar/changesets";
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets";
const SPECIFIC_BRANCH_URL =
"http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets";
"http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
@@ -59,6 +72,102 @@ describe("changesets", () => {
fetchMock.restore();
});
const changesetId = "aba876c0625d90a6aff1494f3d161aaa7008b958";
it("should fetch changeset", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, "{}");
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/" + changesetId
},
{
type: FETCH_CHANGESET_SUCCESS,
payload: {
changeset: {},
id: changesetId,
repository: repository
},
itemId: "foo/bar/" + changesetId
}
];
const store = mockStore({});
return store
.dispatch(fetchChangeset(repository, changesetId))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching changeset on error", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, 500);
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/" + changesetId
}
];
const store = mockStore({});
return store
.dispatch(fetchChangeset(repository, changesetId))
.then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESET_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fetch changeset if needed", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id3", "{}");
const expectedActions = [
{
type: FETCH_CHANGESET_PENDING,
itemId: "foo/bar/id3"
},
{
type: FETCH_CHANGESET_SUCCESS,
payload: {
changeset: {},
id: "id3",
repository: repository
},
itemId: "foo/bar/id3"
}
];
const store = mockStore({});
return store
.dispatch(fetchChangesetIfNeeded(repository, "id3"))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should not fetch changeset if not needed", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id1", 500);
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const store = mockStore(state);
return expect(
store.dispatch(fetchChangesetIfNeeded(repository, "id1"))
).toEqual(undefined);
});
it("should fetch changesets for default branch", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL, "{}");
@@ -69,7 +178,11 @@ describe("changesets", () => {
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: changesets,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
@@ -91,7 +204,11 @@ describe("changesets", () => {
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: changesets,
payload: {
repository,
branch,
changesets
},
itemId
}
];
@@ -150,7 +267,11 @@ describe("changesets", () => {
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: changesets,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
@@ -173,7 +294,11 @@ describe("changesets", () => {
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: changesets,
payload: {
repository,
branch,
changesets
},
itemId: "foo/bar/specific"
}
];
@@ -215,7 +340,7 @@ describe("changesets", () => {
);
expect(newState["foo/bar"].byId["changeset2"].description).toEqual("foo");
expect(newState["foo/bar"].byId["changeset3"].description).toEqual("bar");
expect(newState["foo/bar"].list).toEqual({
expect(newState["foo/bar"].byBranch[""]).toEqual({
entry: {
page: 1,
pageTotal: 10,
@@ -225,6 +350,20 @@ describe("changesets", () => {
});
});
it("should store the changeset list to branch", () => {
const newState = reducer(
{},
fetchChangesetsSuccess(repository, branch, responseBody)
);
expect(newState["foo/bar"].byId["changeset1"]).toBeDefined();
expect(newState["foo/bar"].byBranch["specific"].entries).toEqual([
"changeset1",
"changeset2",
"changeset3"
]);
});
it("should not remove existing changesets", () => {
const state = {
"foo/bar": {
@@ -232,10 +371,12 @@ describe("changesets", () => {
id2: { id: "id2" },
id1: { id: "id1" }
},
list: {
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
};
const newState = reducer(
@@ -245,7 +386,7 @@ describe("changesets", () => {
const fooBar = newState["foo/bar"];
expect(fooBar.list.entries).toEqual([
expect(fooBar.byBranch[""].entries).toEqual([
"changeset1",
"changeset2",
"changeset3"
@@ -253,11 +394,154 @@ describe("changesets", () => {
expect(fooBar.byId["id2"]).toEqual({ id: "id2" });
expect(fooBar.byId["id1"]).toEqual({ id: "id1" });
});
const responseBodySingleChangeset = {
id: "id3",
author: {
mail: "z@phod.com",
name: "zaphod"
},
date: "2018-09-13T08:46:22Z",
description: "added testChangeset",
_links: {},
_embedded: {
tags: [],
branches: []
}
};
it("should add changeset to state", () => {
const newState = reducer(
{
"foo/bar": {
byId: {
"id2": {
id: "id2",
author: { mail: "mail@author.com", name: "author" }
}
},
list: {
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id2"]
}
}
},
fetchChangesetSuccess(responseBodySingleChangeset, repository, "id3")
);
expect(newState).toBeDefined();
expect(newState["foo/bar"].byId["id3"].description).toEqual(
"added testChangeset"
);
expect(newState["foo/bar"].byId["id3"].author.mail).toEqual("z@phod.com");
expect(newState["foo/bar"].byId["id2"]).toBeDefined();
expect(newState["foo/bar"].byId["id3"]).toBeDefined();
expect(newState["foo/bar"].list).toEqual({
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id2"]
});
});
});
describe("changeset selectors", () => {
const error = new Error("Something went wrong");
it("should return changeset", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = getChangeset(state, repository, "id1");
expect(result).toEqual({ id: "id1" });
});
it("should return null if changeset does not exist", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = getChangeset(state, repository, "id3");
expect(result).toEqual(null);
});
it("should return true if changeset does not exist", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = shouldFetchChangeset(state, repository, "id3");
expect(result).toEqual(true);
});
it("should return false if changeset exists", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id1: { id: "id1" },
id2: { id: "id2" }
}
}
}
};
const result = shouldFetchChangeset(state, repository, "id2");
expect(result).toEqual(false);
});
it("should return true, when fetching changeset is pending", () => {
const state = {
pending: {
[FETCH_CHANGESET + "/foo/bar/id1"]: true
}
};
expect(isFetchChangesetPending(state, repository, "id1")).toBeTruthy();
});
it("should return false, when fetching changeset is not pending", () => {
expect(isFetchChangesetPending({}, repository, "id1")).toEqual(false);
});
it("should return error if fetching changeset failed", () => {
const state = {
failure: {
[FETCH_CHANGESET + "/foo/bar/id1"]: error
}
};
expect(getFetchChangesetFailure(state, repository, "id1")).toEqual(error);
});
it("should return false if fetching changeset did not fail", () => {
expect(getFetchChangesetFailure({}, repository, "id1")).toBeUndefined();
});
it("should get all changesets for a given repository", () => {
const state = {
changesets: {
@@ -266,11 +550,13 @@ describe("changesets", () => {
id2: { id: "id2" },
id1: { id: "id1" }
},
list: {
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
}
};
const result = getChangesets(state, repository);
expect(result).toEqual([{ id: "id1" }, { id: "id2" }]);
@@ -303,5 +589,32 @@ describe("changesets", () => {
it("should return false if fetching changesets did not fail", () => {
expect(getFetchChangesetsFailure({}, repository)).toBeUndefined();
});
it("should return list as collection for the default branch", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id2: { id: "id2" },
id1: { id: "id1" }
},
byBranch: {
"": {
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["id1", "id2"]
}
}
}
}
};
const collection = selectListAsCollection(state, repository);
expect(collection.page).toBe(1);
expect(collection.pageTotal).toBe(10);
});
});
});

View File

@@ -3,15 +3,12 @@ import React from "react";
import {translate} from "react-i18next";
import {Checkbox, InputField, SubmitButton} from "@scm-manager/ui-components";
import TypeSelector from "./TypeSelector";
import type {
PermissionCollection,
PermissionEntry
} from "@scm-manager/ui-types";
import type {PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types";
import * as validator from "./permissionValidation";
type Props = {
t: string => string,
createPermission: (permission: PermissionEntry) => void,
createPermission: (permission: PermissionCreateEntry) => void,
loading: boolean,
currentPermissions: PermissionCollection
};
@@ -65,7 +62,7 @@ class CreatePermissionForm extends React.Component<Props, State> {
<SubmitButton
label={t("permission.add-permission.submit-button")}
loading={loading}
disabled={!this.state.valid || this.state.name == ""}
disabled={!this.state.valid || this.state.name === ""}
/>
</form>
</div>

View File

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

View File

@@ -17,7 +17,8 @@ describe("permission validation", () => {
{
name: "PermissionName",
groupPermission: true,
type: "READ"
type: "READ",
_links: {}
}
];
const name = "PermissionName";
@@ -33,7 +34,8 @@ describe("permission validation", () => {
{
name: "PermissionName",
groupPermission: false,
type: "READ"
type: "READ",
_links: {}
}
];
const name = "PermissionName";

View File

@@ -3,26 +3,22 @@ import React from "react";
import {connect} from "react-redux";
import {translate} from "react-i18next";
import {
createPermission,
createPermissionReset,
deletePermissionReset,
fetchPermissions,
getCreatePermissionFailure,
getDeletePermissionsFailure,
getFetchPermissionsFailure,
isFetchPermissionsPending,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
createPermission,
isCreatePermissionPending,
getCreatePermissionFailure,
createPermissionReset,
getDeletePermissionsFailure,
getModifyPermissionsFailure,
modifyPermissionReset,
deletePermissionReset
isFetchPermissionsPending,
modifyPermissionReset
} from "../modules/permissions";
import { Loading, ErrorPage } from "@scm-manager/ui-components";
import type {
Permission,
PermissionCollection,
PermissionEntry
} from "@scm-manager/ui-types";
import {ErrorPage, Loading} from "@scm-manager/ui-components";
import type {Permission, PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types";
import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "../components/CreatePermissionForm";
import type {History} from "history";
@@ -39,7 +35,7 @@ type Props = {
//dispatch functions
fetchPermissions: (namespace: string, repoName: string) => void,
createPermission: (
permission: PermissionEntry,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
@@ -176,7 +172,7 @@ const mapDispatchToProps = dispatch => {
dispatch(fetchPermissions(namespace, repoName));
},
createPermission: (
permission: PermissionEntry,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void

View File

@@ -1,13 +1,9 @@
// @flow
import type {Action} from "@scm-manager/ui-components";
import {apiClient} from "@scm-manager/ui-components";
import * as types from "../../../modules/types";
import type { Action } from "@scm-manager/ui-components";
import type {
PermissionCollection,
Permission,
PermissionEntry
} from "@scm-manager/ui-types";
import type {Permission, PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types";
import {isPending} from "../../../modules/pending";
import {getFailure} from "../../../modules/failure";
import {Dispatch} from "redux";
@@ -219,7 +215,7 @@ export function modifyPermissionReset(namespace: string, repoName: string) {
// create permission
export function createPermission(
permission: PermissionEntry,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
@@ -260,7 +256,7 @@ export function createPermission(
}
export function createPermissionPending(
permission: PermissionEntry,
permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {
@@ -272,7 +268,7 @@ export function createPermissionPending(
}
export function createPermissionSuccess(
permission: PermissionEntry,
permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {

View File

@@ -3,42 +3,42 @@ import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
fetchPermissions,
fetchPermissionsSuccess,
getPermissionsOfRepo,
isFetchPermissionsPending,
getFetchPermissionsFailure,
modifyPermission,
modifyPermissionSuccess,
getModifyPermissionFailure,
isModifyPermissionPending,
createPermission,
hasCreatePermission,
deletePermission,
deletePermissionSuccess,
getDeletePermissionFailure,
isDeletePermissionPending,
getModifyPermissionsFailure,
MODIFY_PERMISSION_FAILURE,
MODIFY_PERMISSION_PENDING,
FETCH_PERMISSIONS,
FETCH_PERMISSIONS_PENDING,
FETCH_PERMISSIONS_SUCCESS,
FETCH_PERMISSIONS_FAILURE,
MODIFY_PERMISSION_SUCCESS,
MODIFY_PERMISSION,
CREATE_PERMISSION,
CREATE_PERMISSION_FAILURE,
CREATE_PERMISSION_PENDING,
CREATE_PERMISSION_SUCCESS,
CREATE_PERMISSION_FAILURE,
createPermission,
createPermissionSuccess,
DELETE_PERMISSION,
DELETE_PERMISSION_FAILURE,
DELETE_PERMISSION_PENDING,
DELETE_PERMISSION_SUCCESS,
DELETE_PERMISSION_FAILURE,
CREATE_PERMISSION,
createPermissionSuccess,
deletePermission,
deletePermissionSuccess,
FETCH_PERMISSIONS,
FETCH_PERMISSIONS_FAILURE,
FETCH_PERMISSIONS_PENDING,
FETCH_PERMISSIONS_SUCCESS,
fetchPermissions,
fetchPermissionsSuccess,
getCreatePermissionFailure,
getDeletePermissionFailure,
getDeletePermissionsFailure,
getFetchPermissionsFailure,
getModifyPermissionFailure,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
isCreatePermissionPending,
getDeletePermissionsFailure
isDeletePermissionPending,
isFetchPermissionsPending,
isModifyPermissionPending,
MODIFY_PERMISSION,
MODIFY_PERMISSION_FAILURE,
MODIFY_PERMISSION_PENDING,
MODIFY_PERMISSION_SUCCESS,
modifyPermission,
modifyPermissionSuccess
} from "./permissions";
import type {Permission, PermissionCollection} from "@scm-manager/ui-types";
@@ -640,7 +640,7 @@ describe("permissions selectors", () => {
it("should return true, when createPermission is true", () => {
const state = {
permissions: {
["hitchhiker/puzzle42"]: {
"hitchhiker/puzzle42": {
createPermission: true
}
}
@@ -651,7 +651,7 @@ describe("permissions selectors", () => {
it("should return false, when createPermission is false", () => {
const state = {
permissions: {
["hitchhiker/puzzle42"]: {
"hitchhiker/puzzle42": {
createPermission: false
}
}

View File

@@ -5,7 +5,6 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.RevisionNotFoundException;
import sonia.scm.repository.api.DiffFormat;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil;
@@ -58,7 +57,7 @@ public class DiffRootResource {
try {
repositoryService.getDiffCommand()
.setRevision(revision)
.setFormat(DiffFormat.GIT) // TODO: Configure this at request time. Maybe as a query param?
// .setFormat(DiffFormat.GIT) // TODO: Configure this at request time. Maybe as a query param?
.retriveContent(output);
} catch (RevisionNotFoundException e) {
throw new WebApplicationException(Response.Status.NOT_FOUND);