mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 15:05:44 +01:00
scm-ui: new repository layout
This commit is contained in:
22
scm-ui/ui-webapp/src/repos/sources/components/FileIcon.js
Normal file
22
scm-ui/ui-webapp/src/repos/sources/components/FileIcon.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// @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.subRepository) {
|
||||
icon = "folder-plus";
|
||||
} else if (file.directory) {
|
||||
icon = "folder";
|
||||
}
|
||||
return <i className={`fa fa-${icon}`} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default FileIcon;
|
||||
168
scm-ui/ui-webapp/src/repos/sources/components/FileTree.js
Normal file
168
scm-ui/ui-webapp/src/repos/sources/components/FileTree.js
Normal file
@@ -0,0 +1,168 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import injectSheet from "react-jss";
|
||||
import FileTreeLeaf from "./FileTreeLeaf";
|
||||
import type { Repository, File } from "@scm-manager/ui-types";
|
||||
import {
|
||||
ErrorNotification,
|
||||
Loading,
|
||||
Notification
|
||||
} from "@scm-manager/ui-components";
|
||||
import {
|
||||
getFetchSourcesFailure,
|
||||
isFetchSourcesPending,
|
||||
getSources
|
||||
} from "../modules/sources";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { compose } from "redux";
|
||||
import { binder } from "@scm-manager/ui-extensions";
|
||||
|
||||
const styles = {
|
||||
iconColumn: {
|
||||
width: "16px"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
tree: File,
|
||||
repository: Repository,
|
||||
revision: string,
|
||||
path: string,
|
||||
baseUrl: string,
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string,
|
||||
match: any
|
||||
};
|
||||
|
||||
export function findParent(path: string) {
|
||||
if (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
|
||||
const index = path.lastIndexOf("/");
|
||||
if (index > 0) {
|
||||
return path.substring(0, index);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
class FileTree extends React.Component<Props> {
|
||||
render() {
|
||||
const { error, loading, tree } = this.props;
|
||||
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="panel-block">{this.renderSourcesTable()}</div>;
|
||||
}
|
||||
|
||||
renderSourcesTable() {
|
||||
const { tree, revision, path, baseUrl, classes, t } = this.props;
|
||||
|
||||
const files = [];
|
||||
|
||||
if (path) {
|
||||
files.push({
|
||||
name: "..",
|
||||
path: findParent(path),
|
||||
directory: true
|
||||
});
|
||||
}
|
||||
|
||||
const compareFiles = function(f1: File, f2: File): number {
|
||||
if (f1.directory) {
|
||||
if (f2.directory) {
|
||||
return f1.name.localeCompare(f2.name);
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (f2.directory) {
|
||||
return 1;
|
||||
} else {
|
||||
return f1.name.localeCompare(f2.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (tree._embedded && tree._embedded.children) {
|
||||
files.push(...tree._embedded.children.sort(compareFiles));
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
let baseUrlWithRevision = baseUrl;
|
||||
if (revision) {
|
||||
baseUrlWithRevision += "/" + encodeURIComponent(revision);
|
||||
} else {
|
||||
baseUrlWithRevision += "/" + encodeURIComponent(tree.revision);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table table-hover table-sm is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={classes.iconColumn} />
|
||||
<th>{t("sources.file-tree.name")}</th>
|
||||
<th className="is-hidden-mobile">
|
||||
{t("sources.file-tree.length")}
|
||||
</th>
|
||||
<th className="is-hidden-mobile">
|
||||
{t("sources.file-tree.lastModified")}
|
||||
</th>
|
||||
<th className="is-hidden-mobile">
|
||||
{t("sources.file-tree.description")}
|
||||
</th>
|
||||
{binder.hasExtension("repos.sources.tree.row.right") && (
|
||||
<th className="is-hidden-mobile" />
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map(file => (
|
||||
<FileTreeLeaf
|
||||
key={file.name}
|
||||
file={file}
|
||||
baseUrl={baseUrlWithRevision}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
return <Notification type="info">{t("sources.noSources")}</Notification>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const { repository, revision, path } = ownProps;
|
||||
|
||||
const loading = isFetchSourcesPending(state, repository, revision, path);
|
||||
const error = getFetchSourcesFailure(state, repository, revision, path);
|
||||
const tree = getSources(state, repository, revision, path);
|
||||
|
||||
return {
|
||||
revision,
|
||||
path,
|
||||
loading,
|
||||
error,
|
||||
tree
|
||||
};
|
||||
};
|
||||
|
||||
export default compose(
|
||||
withRouter,
|
||||
connect(mapStateToProps)
|
||||
)(injectSheet(styles)(translate("repos")(FileTree)));
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
|
||||
import { findParent } from "./FileTree";
|
||||
|
||||
describe("find parent tests", () => {
|
||||
it("should return the parent path", () => {
|
||||
expect(findParent("src/main/js/")).toBe("src/main");
|
||||
expect(findParent("src/main/js")).toBe("src/main");
|
||||
expect(findParent("src/main")).toBe("src");
|
||||
expect(findParent("src")).toBe("");
|
||||
});
|
||||
});
|
||||
110
scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.js
Normal file
110
scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.js
Normal file
@@ -0,0 +1,110 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import { DateFromNow, FileSize } from "@scm-manager/ui-components";
|
||||
import FileIcon from "./FileIcon";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { File } from "@scm-manager/ui-types";
|
||||
import classNames from "classnames";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
const styles = {
|
||||
iconColumn: {
|
||||
width: "16px"
|
||||
},
|
||||
wordBreakMinWidth: {
|
||||
minWidth: "10em"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
file: File,
|
||||
baseUrl: string,
|
||||
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
export function createLink(base: string, file: File) {
|
||||
let link = base;
|
||||
if (file.path) {
|
||||
let path = file.path;
|
||||
if (path.startsWith("/")) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
link += "/" + path;
|
||||
}
|
||||
if (!link.endsWith("/")) {
|
||||
link += "/";
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
class FileTreeLeaf extends React.Component<Props> {
|
||||
createLink = (file: File) => {
|
||||
return createLink(this.props.baseUrl, file);
|
||||
};
|
||||
|
||||
createFileIcon = (file: File) => {
|
||||
if (file.directory) {
|
||||
return (
|
||||
<Link to={this.createLink(file)}>
|
||||
<FileIcon file={file} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
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 <Link to={this.createLink(file)}>{file.name}</Link>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { file, classes } = this.props;
|
||||
|
||||
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={classes.iconColumn}>{this.createFileIcon(file)}</td>
|
||||
<td className={classNames(classes.wordBreakMinWidth, "is-word-break")}>
|
||||
{this.createFileName(file)}
|
||||
</td>
|
||||
<td className="is-hidden-mobile">{fileSize}</td>
|
||||
<td className="is-hidden-mobile">
|
||||
<DateFromNow date={file.lastModified} />
|
||||
</td>
|
||||
<td
|
||||
className={classNames(
|
||||
classes.wordBreakMinWidth,
|
||||
"is-word-break",
|
||||
"is-hidden-mobile"
|
||||
)}
|
||||
>
|
||||
{file.description}
|
||||
</td>
|
||||
{binder.hasExtension("repos.sources.tree.row.right") && (
|
||||
<td className="is-hidden-mobile">
|
||||
{!file.directory && (
|
||||
<ExtensionPoint
|
||||
name="repos.sources.tree.row.right"
|
||||
props={{ file }}
|
||||
renderAll={true}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(FileTreeLeaf);
|
||||
@@ -0,0 +1,30 @@
|
||||
// @flow
|
||||
|
||||
import { createLink } from "./FileTreeLeaf";
|
||||
import type { File } from "@scm-manager/ui-types";
|
||||
|
||||
describe("create link tests", () => {
|
||||
function dir(path: string): File {
|
||||
return {
|
||||
name: "dir",
|
||||
path: path,
|
||||
directory: true,
|
||||
length: 1,
|
||||
revision: "1a",
|
||||
_links: {},
|
||||
_embedded: {
|
||||
children: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it("should create link", () => {
|
||||
expect(createLink("src", dir("main"))).toBe("src/main/");
|
||||
expect(createLink("src", dir("/main"))).toBe("src/main/");
|
||||
expect(createLink("src", dir("/main/"))).toBe("src/main/");
|
||||
});
|
||||
|
||||
it("should return base url if the directory path is empty", () => {
|
||||
expect(createLink("src", dir(""))).toBe("src/");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { ButtonAddons, Button } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
historyIsSelected: boolean,
|
||||
showHistory: boolean => void
|
||||
};
|
||||
|
||||
class FileButtonGroup extends React.Component<Props> {
|
||||
showHistory = () => {
|
||||
this.props.showHistory(true);
|
||||
};
|
||||
|
||||
showSources = () => {
|
||||
this.props.showHistory(false);
|
||||
};
|
||||
|
||||
color = (selected: boolean) => {
|
||||
return selected ? "link is-selected" : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, historyIsSelected } = this.props;
|
||||
|
||||
return (
|
||||
<ButtonAddons>
|
||||
<div title={t("sources.content.sourcesButton")}>
|
||||
<Button
|
||||
action={this.showSources}
|
||||
className="reduced"
|
||||
color={this.color(!historyIsSelected)}
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-code" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div title={t("sources.content.historyButton")}>
|
||||
<Button
|
||||
action={this.showHistory}
|
||||
className="reduced"
|
||||
color={this.color(historyIsSelected)}
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-history" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</ButtonAddons>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(FileButtonGroup);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,92 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { apiClient, SyntaxHighlighter } from "@scm-manager/ui-components";
|
||||
import type { File } from "@scm-manager/ui-types";
|
||||
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
|
||||
|
||||
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
|
||||
language={getLanguage(language)}
|
||||
value= {content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user