implemented ui for sources root

This commit is contained in:
Sebastian Sdorra
2018-09-27 16:32:37 +02:00
parent b011056352
commit 2b7453fc57
15 changed files with 640 additions and 2 deletions

View File

@@ -0,0 +1,27 @@
// @flow
import type { Collection, Links } from "./hal";
// TODO ?? check ?? links
export type SubRepository = {
repositoryUrl: string,
browserUrl: string,
revision: string
};
export type File = {
name: string,
path: string,
directory: boolean,
description?: string,
length: number,
lastModified?: string,
subRepository?: SubRepository, // TODO
_links: Links
};
export type SourcesCollection = Collection & {
_embedded: {
files: File[]
}
};

View File

@@ -10,3 +10,5 @@ export type { Repository, RepositoryCollection, RepositoryGroup } from "./Reposi
export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes";
export type { Config } from "./Config"; export type { Config } from "./Config";
export type { SubRepository, File, SourcesCollection } from "./Sources";

View File

@@ -22,7 +22,8 @@
"actions-label": "Actions", "actions-label": "Actions",
"back-label": "Back", "back-label": "Back",
"navigation-label": "Navigation", "navigation-label": "Navigation",
"information": "Information" "information": "Information",
"sources": "Sources"
}, },
"create": { "create": {
"title": "Create Repository", "title": "Create Repository",
@@ -42,5 +43,13 @@
"submit": "Yes", "submit": "Yes",
"cancel": "No" "cancel": "No"
} }
},
"sources": {
"file-tree": {
"name": "Name",
"length": "Length",
"lastModified": "Last modified",
"description": "Description"
}
} }
} }

View File

@@ -7,6 +7,7 @@ import { routerReducer, routerMiddleware } from "react-router-redux";
import users from "./users/modules/users"; import users from "./users/modules/users";
import repos from "./repos/modules/repos"; import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes"; import repositoryTypes from "./repos/modules/repositoryTypes";
import sources from "./repos/sources/modules/sources";
import groups from "./groups/modules/groups"; import groups from "./groups/modules/groups";
import auth from "./modules/auth"; import auth from "./modules/auth";
import pending from "./modules/pending"; import pending from "./modules/pending";
@@ -28,7 +29,8 @@ function createReduxStore(history: BrowserHistory) {
repositoryTypes, repositoryTypes,
groups, groups,
auth, auth,
config config,
sources
}); });
return createStore( return createStore(

View File

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

View File

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

View File

@@ -25,6 +25,8 @@ import Edit from "../containers/Edit";
import type { History } from "history"; import type { History } from "history";
import EditNavLink from "../components/EditNavLink"; import EditNavLink from "../components/EditNavLink";
import Sources from "../sources/containers/Sources";
import RepositoryNavLink from "../components/RepositoryNavLink";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -101,12 +103,22 @@ class RepositoryRoot extends React.Component<Props> {
path={`${url}/edit`} path={`${url}/edit`}
component={() => <Edit repository={repository} />} component={() => <Edit repository={repository} />}
/> />
<Route
path={`${url}/sources`}
component={() => <Sources repository={repository} />}
/>
</div> </div>
<div className="column"> <div className="column">
<Navigation> <Navigation>
<Section label={t("repository-root.navigation-label")}> <Section label={t("repository-root.navigation-label")}>
<NavLink to={url} label={t("repository-root.information")} /> <NavLink to={url} label={t("repository-root.information")} />
<EditNavLink repository={repository} editUrl={`${url}/edit`} /> <EditNavLink repository={repository} editUrl={`${url}/edit`} />
<RepositoryNavLink
repository={repository}
linkName="sources"
to={`${url}/sources`}
label={t("repository-root.sources")}
/>
</Section> </Section>
<Section label={t("repository-root.actions-label")}> <Section label={t("repository-root.actions-label")}>
<DeleteNavAction repository={repository} delete={this.delete} /> <DeleteNavAction repository={repository} delete={this.delete} />

View File

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

View File

@@ -0,0 +1,27 @@
// @flow
import React from "react";
type Props = {
bytes: number
};
class FileSize extends React.Component<Props> {
static format(bytes) {
if (!bytes) {
return "";
}
const units = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = (bytes / 1024 ** i).toFixed(2);
return `${size} ${units[i]}`;
}
render() {
const formattedBytes = FileSize.format(this.props.bytes);
return <span>{formattedBytes}</span>;
}
}
export default FileSize;

View File

@@ -0,0 +1,9 @@
import FileSize from "./FileSize";
it("should format bytes", () => {
expect(FileSize.format(160)).toBe("160.00 B");
expect(FileSize.format(6304)).toBe("6.16 K");
expect(FileSize.format(28792588)).toBe("27.46 M");
expect(FileSize.format(1369510189)).toBe("1.28 G");
expect(FileSize.format(42949672960)).toBe("40.00 G");
});

View File

@@ -0,0 +1,48 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import injectSheet from "react-jss";
import FileTreeLeaf from "./FileTreeLeaf";
import type { SourcesCollection } from "@scm-manager/ui-types";
const styles = {
iconColumn: {
width: "16px"
}
};
type Props = {
tree: SourcesCollection,
// context props
classes: any,
t: string => string
};
class FileTree extends React.Component<Props> {
render() {
const { tree, classes, t } = this.props;
const files = tree._embedded.files;
return (
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<th className={classes.iconColumn} />
<th>{t("sources.file-tree.name")}</th>
<th>{t("sources.file-tree.length")}</th>
<th>{t("sources.file-tree.lastModified")}</th>
<th>{t("sources.file-tree.description")}</th>
</tr>
</thead>
<tbody>
{files.map(file => (
<FileTreeLeaf key={file.name} file={file} />
))}
</tbody>
</table>
);
}
}
export default injectSheet(styles)(translate("repos")(FileTree));

View File

@@ -0,0 +1,49 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { DateFromNow } from "@scm-manager/ui-components";
import FileSize from "./FileSize";
import FileIcon from "./FileIcon";
import { Link } from "react-router-dom";
import type { File } from "@scm-manager/ui-types";
const styles = {
iconColumn: {
width: "16px"
}
};
type Props = {
file: File,
// context props
classes: any
};
class FileTreeLeaf extends React.Component<Props> {
render() {
const { file, classes } = this.props;
return (
<tr>
<td className={classes.iconColumn}>
<Link to="#todo">
<FileIcon file={file} />
</Link>
</td>
<td>
<Link to="#todo">{file.name}</Link>
</td>
<td>
<FileSize bytes={file.length} />
</td>
<td>
<DateFromNow date={file.lastModified} />
</td>
<td>{file.description}</td>
</tr>
);
}
}
export default injectSheet(styles)(FileTreeLeaf);

View File

@@ -0,0 +1,72 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import type { Repository, SourcesCollection } from "@scm-manager/ui-types";
import FileTree from "../components/FileTree";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import {
fetchSources,
getFetchSourcesFailure,
getSources,
isFetchSourcesPending
} from "../modules/sources";
type Props = {
repository: Repository,
sources: SourcesCollection,
loading: boolean,
error: Error,
// dispatch props
fetchSources: (repository: Repository) => void
};
class Sources extends React.Component<Props> {
componentDidMount() {
const { fetchSources, repository } = this.props;
fetchSources(repository);
}
render() {
const { sources, loading, error } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!sources || loading) {
return <Loading />;
}
return <FileTree tree={sources} />;
}
}
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const loading = isFetchSourcesPending(state, repository);
const error = getFetchSourcesFailure(state, repository);
const sources = getSources(state, repository);
console.log(sources);
return {
loading,
error,
sources
};
};
const mapDispatchToProps = dispatch => {
return {
fetchSources: (repository: Repository) => {
dispatch(fetchSources(repository));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Sources);

View File

@@ -0,0 +1,106 @@
// @flow
import * as types from "../../../modules/types";
import type {
Repository,
SourcesCollection,
Action
} from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
export const FETCH_SOURCES = "scm/repos/FETCH_SOURCES";
export const FETCH_SOURCES_PENDING = `${FETCH_SOURCES}_${types.PENDING_SUFFIX}`;
export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`;
export function fetchSources(repository: Repository) {
return function(dispatch: any) {
dispatch(fetchSourcesPending(repository));
return apiClient
.get(repository._links.sources.href)
.then(response => response.json())
.then(sources => {
dispatch(fetchSourcesSuccess(repository, sources));
})
.catch(err => {
const error = new Error(`failed to fetch sources: ${err.message}`);
dispatch(fetchSourcesFailure(repository, error));
});
};
}
export function fetchSourcesPending(repository: Repository): Action {
return {
type: FETCH_SOURCES_PENDING,
itemId: createItemId(repository)
};
}
export function fetchSourcesSuccess(
repository: Repository,
sources: SourcesCollection
) {
return {
type: FETCH_SOURCES_SUCCESS,
payload: sources,
itemId: createItemId(repository)
};
}
export function fetchSourcesFailure(
repository: Repository,
error: Error
): Action {
return {
type: FETCH_SOURCES_FAILURE,
payload: error,
itemId: createItemId(repository)
};
}
function createItemId(repository: Repository) {
return `${repository.namespace}/${repository.name}`;
}
// reducer
export default function reducer(
state: any = {},
action: Action = { type: "UNKNOWN" }
): any {
if (action.type === FETCH_SOURCES_SUCCESS) {
return {
[action.itemId]: action.payload,
...state
};
}
return state;
}
// selectors
export function getSources(
state: any,
repository: Repository
): ?SourcesCollection {
if (state.sources) {
return state.sources[createItemId(repository)];
}
return null;
}
export function isFetchSourcesPending(
state: any,
repository: Repository
): boolean {
return isPending(state, FETCH_SOURCES, createItemId(repository));
}
export function getFetchSourcesFailure(
state: any,
repository: Repository
): ?Error {
return getFailure(state, FETCH_SOURCES, createItemId(repository));
}

View File

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