From 2b7453fc57857956d692e39bb79394b04a431b5a Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Thu, 27 Sep 2018 16:32:37 +0200 Subject: [PATCH] implemented ui for sources root --- .../packages/ui-types/src/Sources.js | 27 +++ .../packages/ui-types/src/index.js | 2 + scm-ui/public/locales/en/repos.json | 11 +- scm-ui/src/createReduxStore.js | 4 +- .../src/repos/components/RepositoryNavLink.js | 28 +++ .../components/RepositoryNavLink.test.js | 49 +++++ scm-ui/src/repos/containers/RepositoryRoot.js | 12 ++ .../src/repos/sources/components/FileIcon.js | 20 ++ .../src/repos/sources/components/FileSize.js | 27 +++ .../repos/sources/components/FileSize.test.js | 9 + .../src/repos/sources/components/FileTree.js | 48 +++++ .../repos/sources/components/FileTreeLeaf.js | 49 +++++ .../src/repos/sources/containers/Sources.js | 72 +++++++ scm-ui/src/repos/sources/modules/sources.js | 106 +++++++++++ .../src/repos/sources/modules/sources.test.js | 178 ++++++++++++++++++ 15 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 scm-ui-components/packages/ui-types/src/Sources.js create mode 100644 scm-ui/src/repos/components/RepositoryNavLink.js create mode 100644 scm-ui/src/repos/components/RepositoryNavLink.test.js create mode 100644 scm-ui/src/repos/sources/components/FileIcon.js create mode 100644 scm-ui/src/repos/sources/components/FileSize.js create mode 100644 scm-ui/src/repos/sources/components/FileSize.test.js create mode 100644 scm-ui/src/repos/sources/components/FileTree.js create mode 100644 scm-ui/src/repos/sources/components/FileTreeLeaf.js create mode 100644 scm-ui/src/repos/sources/containers/Sources.js create mode 100644 scm-ui/src/repos/sources/modules/sources.js create mode 100644 scm-ui/src/repos/sources/modules/sources.test.js diff --git a/scm-ui-components/packages/ui-types/src/Sources.js b/scm-ui-components/packages/ui-types/src/Sources.js new file mode 100644 index 0000000000..0aead5d05c --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Sources.js @@ -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[] + } +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index 109fb9bb6a..60a259f0f2 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -10,3 +10,5 @@ export type { Repository, RepositoryCollection, RepositoryGroup } from "./Reposi export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export type { Config } from "./Config"; + +export type { SubRepository, File, SourcesCollection } from "./Sources"; diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 7db2623247..b8bbe19c4f 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -22,7 +22,8 @@ "actions-label": "Actions", "back-label": "Back", "navigation-label": "Navigation", - "information": "Information" + "information": "Information", + "sources": "Sources" }, "create": { "title": "Create Repository", @@ -42,5 +43,13 @@ "submit": "Yes", "cancel": "No" } + }, + "sources": { + "file-tree": { + "name": "Name", + "length": "Length", + "lastModified": "Last modified", + "description": "Description" + } } } diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index 8411326b53..b2e09dd65b 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -7,6 +7,7 @@ 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 sources from "./repos/sources/modules/sources"; import groups from "./groups/modules/groups"; import auth from "./modules/auth"; import pending from "./modules/pending"; @@ -28,7 +29,8 @@ function createReduxStore(history: BrowserHistory) { repositoryTypes, groups, auth, - config + config, + sources }); return createStore( diff --git a/scm-ui/src/repos/components/RepositoryNavLink.js b/scm-ui/src/repos/components/RepositoryNavLink.js new file mode 100644 index 0000000000..8497e207cd --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.js @@ -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 { + render() { + const { linkName, to, label, repository } = this.props; + + if (!repository._links[linkName]) { + return null; + } + + return ; + } +} + +export default RepositoryNavLink; diff --git a/scm-ui/src/repos/components/RepositoryNavLink.test.js b/scm-ui/src/repos/components/RepositoryNavLink.test.js new file mode 100644 index 0000000000..0d93cb7c4d --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.test.js @@ -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( + , + options.get() + ); + expect(navLink.text()).toBe(""); + }); + + it("should render the navLink", () => { + const repository = { + _links: { + sources: { + href: "/sources" + } + } + }; + + const navLink = mount( + , + options.get() + ); + expect(navLink.text()).toBe("Sources"); + }); +}); diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 2816cdb5c5..78041764c7 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -25,6 +25,8 @@ import Edit from "../containers/Edit"; import type { History } from "history"; import EditNavLink from "../components/EditNavLink"; +import Sources from "../sources/containers/Sources"; +import RepositoryNavLink from "../components/RepositoryNavLink"; type Props = { namespace: string, @@ -101,12 +103,22 @@ class RepositoryRoot extends React.Component { path={`${url}/edit`} component={() => } /> + } + />
+
diff --git a/scm-ui/src/repos/sources/components/FileIcon.js b/scm-ui/src/repos/sources/components/FileIcon.js new file mode 100644 index 0000000000..4615c0cddc --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileIcon.js @@ -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 { + render() { + const { file } = this.props; + let icon = "file"; + if (file.directory) { + icon = "folder"; + } + return ; + } +} + +export default FileIcon; diff --git a/scm-ui/src/repos/sources/components/FileSize.js b/scm-ui/src/repos/sources/components/FileSize.js new file mode 100644 index 0000000000..1f613ad9fc --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileSize.js @@ -0,0 +1,27 @@ +// @flow +import React from "react"; + +type Props = { + bytes: number +}; + +class FileSize extends React.Component { + 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 {formattedBytes}; + } +} + +export default FileSize; diff --git a/scm-ui/src/repos/sources/components/FileSize.test.js b/scm-ui/src/repos/sources/components/FileSize.test.js new file mode 100644 index 0000000000..1bbe511eb1 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileSize.test.js @@ -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"); +}); diff --git a/scm-ui/src/repos/sources/components/FileTree.js b/scm-ui/src/repos/sources/components/FileTree.js new file mode 100644 index 0000000000..5afb6309ee --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTree.js @@ -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 { + render() { + const { tree, classes, t } = this.props; + + const files = tree._embedded.files; + + return ( + + + + + + + + + + + {files.map(file => ( + + ))} + +
+ {t("sources.file-tree.name")}{t("sources.file-tree.length")}{t("sources.file-tree.lastModified")}{t("sources.file-tree.description")}
+ ); + } +} +export default injectSheet(styles)(translate("repos")(FileTree)); diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.js new file mode 100644 index 0000000000..440a87c8e3 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.js @@ -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 { + render() { + const { file, classes } = this.props; + + return ( + + + + + + + + {file.name} + + + + + + + + {file.description} + + ); + } +} + +export default injectSheet(styles)(FileTreeLeaf); diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js new file mode 100644 index 0000000000..c7903e7ef3 --- /dev/null +++ b/scm-ui/src/repos/sources/containers/Sources.js @@ -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 { + componentDidMount() { + const { fetchSources, repository } = this.props; + + fetchSources(repository); + } + + render() { + const { sources, loading, error } = this.props; + + if (error) { + return ; + } + + if (!sources || loading) { + return ; + } + + return ; + } +} + +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); diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js new file mode 100644 index 0000000000..74bb25b80a --- /dev/null +++ b/scm-ui/src/repos/sources/modules/sources.js @@ -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)); +} diff --git a/scm-ui/src/repos/sources/modules/sources.test.js b/scm-ui/src/repos/sources/modules/sources.test.js new file mode 100644 index 0000000000..3870d638e3 --- /dev/null +++ b/scm-ui/src/repos/sources/modules/sources.test.js @@ -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); + }); +});