Merged in feature/ui-repoContent (pull request #96)

Feature/ui repoContent
This commit is contained in:
Sebastian Sdorra
2018-11-01 10:03:23 +00:00
27 changed files with 757 additions and 66 deletions

View File

@@ -14,7 +14,7 @@ import {
modifyConfigReset
} from "../modules/config";
import { connect } from "react-redux";
import type { Config, Link } from "@scm-manager/ui-types";
import type { Config } from "@scm-manager/ui-types";
import ConfigForm from "../components/form/ConfigForm";
import { getConfigLink } from "../../modules/indexResource";

View File

@@ -22,7 +22,6 @@ import reducer, {
getConfig,
getConfigUpdatePermission
} from "./config";
import { getConfigLink } from "../../modules/indexResource";
const CONFIG_URL = "/config";
const URL = "/api/v2" + CONFIG_URL;

View File

@@ -19,9 +19,8 @@ import {
Footer,
Header
} from "@scm-manager/ui-components";
import type { Me, Link } from "@scm-manager/ui-types";
import type { Me } from "@scm-manager/ui-types";
import {
fetchIndexResources,
getConfigLink,
getFetchIndexResourcesFailure,
getGroupsLink,

View File

@@ -11,7 +11,7 @@ import {
getLogoutFailure
} from "../modules/auth";
import { Loading, ErrorPage } from "@scm-manager/ui-components";
import { fetchIndexResources, getLogoutLink } from "../modules/indexResource";
import { getLogoutLink } from "../modules/indexResource";
type Props = {
authenticated: boolean,

View File

@@ -9,8 +9,7 @@ import {
createGroup,
isCreateGroupPending,
getCreateGroupFailure,
createGroupReset,
getCreateGroupLink
createGroupReset
} from "../modules/groups";
import type { Group } from "@scm-manager/ui-types";
import type { History } from "history";

View File

@@ -14,7 +14,6 @@ import type { BrowserHistory } from "history/createBrowserHistory";
import createReduxStore from "./createReduxStore";
import { ConnectedRouter } from "react-router-redux";
import PluginLoader from "./containers/PluginLoader";
import { urls } from "@scm-manager/ui-components";
@@ -37,7 +36,7 @@ ReactDOM.render(
<I18nextProvider i18n={i18n}>
{/* ConnectedRouter will use the store from Provider automatically */}
<ConnectedRouter history={history}>
<Index />
<Index />
</ConnectedRouter>
</I18nextProvider>
</Provider>,

View File

@@ -7,8 +7,8 @@ import { isPending } from "./pending";
import { getFailure } from "./failure";
import {
callFetchIndexResources,
FETCH_INDEXRESOURCES_SUCCESS,
fetchIndexResources, fetchIndexResourcesPending,
fetchIndexResources,
fetchIndexResourcesPending,
fetchIndexResourcesSuccess
} from "./indexResource";
@@ -156,7 +156,7 @@ export const login = (
return apiClient
.post(loginLink, login_data)
.then(response => {
dispatch(fetchIndexResourcesPending())
dispatch(fetchIndexResourcesPending());
return callFetchIndexResources();
})
.then(response => {

View File

@@ -7,7 +7,6 @@ import FileTreeLeaf from "./FileTreeLeaf";
import type { Repository, File } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import {
fetchSources,
getFetchSourcesFailure,
isFetchSourcesPending,
getSources
@@ -29,7 +28,6 @@ type Props = {
revision: string,
path: string,
baseUrl: string,
fetchSources: (Repository, string, string) => void,
// context props
classes: any,
t: string => string,
@@ -49,19 +47,6 @@ export function findParent(path: string) {
}
class FileTree extends React.Component<Props> {
componentDidMount() {
const { fetchSources, repository, revision, path } = this.props;
fetchSources(repository, revision, path);
}
componentDidUpdate(prevProps) {
const { fetchSources, repository, revision, path } = this.props;
if (prevProps.revision !== revision || prevProps.path !== path) {
fetchSources(repository, revision, path);
}
}
render() {
const {
error,
@@ -167,18 +152,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
};
};
const mapDispatchToProps = dispatch => {
return {
fetchSources: (repository: Repository, revision: string, path: string) => {
dispatch(fetchSources(repository, revision, path));
}
};
};
export default compose(
withRouter,
connect(
mapStateToProps,
mapDispatchToProps
)
connect(mapStateToProps)
)(injectSheet(styles)(translate("repos")(FileTree)));

View File

@@ -49,14 +49,18 @@ class FileTreeLeaf extends React.Component<Props> {
</Link>
);
}
return <FileIcon file={file} />;
return (
<Link to={this.createLink(file)}>
<FileIcon file={file} />
</Link>
);
};
createFileName = (file: File) => {
if (file.directory) {
return <Link to={this.createLink(file)}>{file.name}</Link>;
}
return file.name;
return <Link to={this.createLink(file)}>{file.name}</Link>;
};
render() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,217 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { getSources } from "../modules/sources";
import type { Repository, File } from "@scm-manager/ui-types";
import {
ErrorNotification,
Loading,
DateFromNow
} from "@scm-manager/ui-components";
import { connect } from "react-redux";
import ImageViewer from "../components/content/ImageViewer";
import SourcecodeViewer from "../components/content/SourcecodeViewer";
import DownloadViewer from "../components/content/DownloadViewer";
import FileSize from "../components/FileSize";
import injectSheet from "react-jss";
import classNames from "classnames";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { getContentType } from "./contentType";
type Props = {
loading: boolean,
error: Error,
file: File,
repository: Repository,
revision: string,
path: string,
classes: any,
t: string => string
};
type State = {
contentType: string,
language: string,
loaded: boolean,
collapsed: boolean,
error?: Error
};
const styles = {
toCenterContent: {
display: "block"
},
pointer: {
cursor: "pointer"
}
};
class Content extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
contentType: "",
language: "",
loaded: false,
collapsed: true
};
}
componentDidMount() {
const { file } = this.props;
getContentType(file._links.self.href)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
contentType: result.type,
language: result.language,
loaded: true
});
}
})
.catch(err => {});
}
toggleCollapse = () => {
this.setState(prevState => ({
collapsed: !prevState.collapsed
}));
};
showHeader() {
const { file, classes } = this.props;
const collapsed = this.state.collapsed;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
return (
<span className={classes.pointer} onClick={this.toggleCollapse}>
<article className="media">
<div className="media-left">
<i className={classNames("fa", icon)} />
</div>
<div className="media-content">
<div className="content">{file.name}</div>
</div>
<p className="media-right">{fileSize}</p>
</article>
</span>
);
}
showMoreInformation() {
const collapsed = this.state.collapsed;
const { classes, file, revision } = this.props;
const date = <DateFromNow date={file.lastModified} />;
const description = file.description ? (
<p>
{file.description.split("\n").map((item, key) => {
return (
<span key={key}>
{item}
<br />
</span>
);
})}
</p>
) : null;
if (!collapsed) {
return (
<div className={classNames("panel-block", classes.toCenterContent)}>
<table className="table">
<tbody>
<tr>
<td>Path</td>
<td>{file.path}</td>
</tr>
<tr>
<td>Branch</td>
<td>{revision}</td>
</tr>
<tr>
<td>Last modified</td>
<td>{date}</td>
</tr>
<tr>
<td>Description</td>
<td>{description}</td>
</tr>
</tbody>
</table>
</div>
);
}
return null;
}
showContent() {
const { file, revision } = this.props;
const { contentType, language } = this.state;
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />;
} else {
return (
<ExtensionPoint
name="repos.sources.view"
props={{ file, contentType, revision }}
>
<DownloadViewer file={file} />
</ExtensionPoint>
);
}
}
render() {
const { file, classes } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const header = this.showHeader();
const content = this.showContent();
const moreInformation = this.showMoreInformation();
return (
<div>
<nav className="panel">
<article className="panel-heading">{header}</article>
{moreInformation}
<div className={classNames("panel-block", classes.toCenterContent)}>
{content}
</div>
</nav>
</div>
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
const file = getSources(state, repository, revision, path);
return {
file
};
};
export default injectSheet(styles)(
connect(mapStateToProps)(translate("repos")(Content))
);

View File

@@ -13,6 +13,8 @@ import {
isFetchBranchesPending
} from "../../modules/branches";
import { compose } from "redux";
import Content from "./Content";
import { fetchSources, isDirectory } from "../modules/sources";
type Props = {
repository: Repository,
@@ -22,9 +24,11 @@ type Props = {
branches: Branch[],
revision: string,
path: string,
currentFileIsDirectory: boolean,
// dispatch props
fetchBranches: Repository => void,
fetchSources: (Repository, string, string) => void,
// Context props
history: any,
@@ -33,14 +37,26 @@ type Props = {
class Sources extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
const {
fetchBranches,
repository,
revision,
path,
fetchSources
} = this.props;
fetchBranches(repository);
fetchSources(repository, revision, path);
}
componentDidUpdate(prevProps) {
const { fetchSources, repository, revision, path } = this.props;
if (prevProps.revision !== revision || prevProps.path !== path) {
fetchSources(repository, revision, path);
}
}
branchSelected = (branch?: Branch) => {
const { baseUrl, history, path } = this.props;
let url;
if (branch) {
if (path) {
@@ -55,7 +71,15 @@ class Sources extends React.Component<Props> {
};
render() {
const { repository, baseUrl, loading, error, revision, path } = this.props;
const {
repository,
baseUrl,
loading,
error,
revision,
path,
currentFileIsDirectory
} = this.props;
if (error) {
return <ErrorNotification error={error} />;
@@ -65,21 +89,28 @@ class Sources extends React.Component<Props> {
return <Loading />;
}
return (
<>
{this.renderBranchSelector()}
<FileTree
repository={repository}
revision={revision}
path={path}
baseUrl={baseUrl}
/>
</>
);
if (currentFileIsDirectory) {
return (
<>
{this.renderBranchSelector()}
<FileTree
repository={repository}
revision={revision}
path={path}
baseUrl={baseUrl}
/>
</>
);
} else {
return (
<Content repository={repository} revision={revision} path={path} />
);
}
}
renderBranchSelector = () => {
const { repository, branches, revision } = this.props;
if (repository._links.branches) {
return (
<BranchSelector
@@ -99,10 +130,12 @@ const mapStateToProps = (state, ownProps) => {
const { repository, match } = ownProps;
const { revision, path } = match.params;
const decodedRevision = revision ? decodeURIComponent(revision) : undefined;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const currentFileIsDirectory = decodedRevision
? isDirectory(state, repository, decodedRevision, path)
: isDirectory(state, repository, revision, path);
return {
repository,
@@ -110,7 +143,8 @@ const mapStateToProps = (state, ownProps) => {
path,
loading,
error,
branches
branches,
currentFileIsDirectory
};
};
@@ -118,6 +152,9 @@ const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
},
fetchSources: (repository: Repository, revision: string, path: string) => {
dispatch(fetchSources(repository, revision, path));
}
};
};

View File

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

View File

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

View File

@@ -102,6 +102,20 @@ export default function reducer(
// selectors
export function isDirectory(
state: any,
repository: Repository,
revision: string,
path: string
): boolean {
const currentFile = getSources(state, repository, revision, path);
if (currentFile && !currentFile.directory) {
return false;
} else {
return true; //also return true if no currentFile is found since it is the "default" path
}
}
export function getSources(
state: any,
repository: Repository,

View File

@@ -1,6 +1,6 @@
// @flow
import type { Repository } from "@scm-manager/ui-types";
import type { Repository, File } from "@scm-manager/ui-types";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
@@ -14,7 +14,8 @@ import {
isFetchSourcesPending,
default as reducer,
getSources,
fetchSourcesSuccess
fetchSourcesSuccess,
isDirectory
} from "./sources";
const sourcesUrl =
@@ -79,6 +80,21 @@ const collection = {
}
};
const noDirectory: File = {
name: "src",
path: "src",
directory: true,
length: 176,
revision: "abc",
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src"
}
},
_embedded: collection
};
describe("sources fetch", () => {
const mockStore = configureMockStore([thunk]);
@@ -168,6 +184,28 @@ describe("reducer tests", () => {
});
describe("selector tests", () => {
it("should return false if it is no directory", () => {
const state = {
sources: {
"scm/core/abc/src/main/package.json": {
noDirectory
}
}
};
expect(
isDirectory(state, repository, "abc", "src/main/package.json")
).toBeFalsy();
});
it("should return true if it is directory", () => {
const state = {
sources: {
"scm/core/abc/src": noDirectory
}
};
expect(isDirectory(state, repository, "abc", "src")).toBe(true);
});
it("should return null", () => {
expect(getSources({}, repository)).toBeFalsy();
});
@@ -181,7 +219,7 @@ describe("selector tests", () => {
expect(getSources(state, repository)).toBe(collection);
});
it("should return the source collection without revision and path", () => {
it("should return the source collection with revision and path", () => {
const state = {
sources: {
"scm/core/abc/src/main": collection