Introduce stale while revalidate pattern (#1555)

This Improves the frontend performance with stale while
revalidate pattern.

There are noticeable performance problems in the frontend that
needed addressing. While implementing the stale-while-revalidate
pattern to display cached responses while re-fetching up-to-date
data in the background, in the same vein we used the opportunity
to remove legacy code involving redux as much as possible,
cleaned up many components and converted them to functional
react components.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-02-24 08:17:40 +01:00
committed by GitHub
parent ad5c8102c0
commit 3a8d031ed5
243 changed files with 150259 additions and 80227 deletions

View File

@@ -21,14 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FormEvent } from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Branch, BranchRequest, Repository } from "@scm-manager/ui-types";
import { Branch, BranchCreation, Repository } from "@scm-manager/ui-types";
import { InputField, Level, Select, SubmitButton, validation as validator } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
type Props = WithTranslation & {
submitForm: (p: BranchRequest) => void;
submitForm: (p: BranchCreation) => void;
repository: Repository;
branches: Branch[];
loading?: boolean;
@@ -52,7 +52,7 @@ class BranchForm extends React.Component<Props, State> {
};
}
isFalsy(value) {
isFalsy(value?: string) {
return !value;
}
@@ -61,12 +61,12 @@ class BranchForm extends React.Component<Props, State> {
return !(this.state.nameValidationError || this.isFalsy(source) || this.isFalsy(name));
};
submit = (event: Event) => {
submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm({
name: this.state.name,
parent: this.state.source
name: this.state.name!,
parent: this.state.source!
});
}
};

View File

@@ -21,24 +21,35 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BranchRow from "./BranchRow";
import { Branch, Link } from "@scm-manager/ui-types";
import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { Branch, Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeleteBranch } from "@scm-manager/ui-api";
type Props = {
baseUrl: string;
repository: Repository;
branches: Branch[];
type: string;
fetchBranches: () => void;
};
const BranchTable: FC<Props> = ({ baseUrl, branches, type, fetchBranches }) => {
const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
const { isLoading, error, remove, isDeleted } = useDeleteBranch(repository);
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [branchToBeDeleted, setBranchToBeDeleted] = useState<Branch | undefined>();
useEffect(() => {
if (isDeleted) {
closeAndResetDialog();
}
}, [isDeleted]);
const closeAndResetDialog = () => {
setBranchToBeDeleted(undefined);
setShowConfirmAlert(false);
};
const onDelete = (branch: Branch) => {
setBranchToBeDeleted(branch);
@@ -46,57 +57,48 @@ const BranchTable: FC<Props> = ({ baseUrl, branches, type, fetchBranches }) => {
};
const abortDelete = () => {
setBranchToBeDeleted(undefined);
setShowConfirmAlert(false);
closeAndResetDialog();
};
const deleteBranch = () => {
apiClient
.delete((branchToBeDeleted?._links.delete as Link).href)
.then(() => fetchBranches())
.catch(setError);
};
const renderRow = () => {
let rowContent = null;
if (branches) {
rowContent = branches.map((branch, index) => {
return <BranchRow key={index} baseUrl={baseUrl} branch={branch} onDelete={onDelete} />;
});
if (branchToBeDeleted) {
remove(branchToBeDeleted);
}
return rowContent;
};
const confirmAlert = (
<ConfirmAlert
title={t("branch.delete.confirmAlert.title")}
message={t("branch.delete.confirmAlert.message", { branch: branchToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
);
return (
<>
{showConfirmAlert && confirmAlert}
{error && <ErrorNotification error={error} />}
{showConfirmAlert ? (
<ConfirmAlert
title={t("branch.delete.confirmAlert.title")}
message={t("branch.delete.confirmAlert.message", { branch: branchToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
isLoading,
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
) : null}
{error ? <ErrorNotification error={error} /> : null}
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
<th>{t(`branches.table.branches.${type}`)}</th>
</tr>
</thead>
<tbody>{renderRow()}</tbody>
<tbody>
{(branches || []).map(branch => (
<BranchRow key={branch.name} baseUrl={baseUrl} branch={branch} onDelete={onDelete} />
))}
</tbody>
</table>
</>
);

View File

@@ -21,90 +21,54 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import BranchView from "../components/BranchView";
import { connect } from "react-redux";
import { compose } from "redux";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import { Branch, Repository } from "@scm-manager/ui-types";
import { fetchBranch, getBranch, getFetchBranchFailure, isFetchBranchPending } from "../modules/branches";
import { Redirect, Route, Switch, useLocation, useRouteMatch } from "react-router-dom";
import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, NotFoundError, urls } from "@scm-manager/ui-components";
import { History } from "history";
import queryString from "query-string";
import { useBranch } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
branchName: string;
branch: Branch;
loading: boolean;
error?: Error;
// context props
history: History;
match: any;
location: any;
// dispatch functions
fetchBranch: (repository: Repository, branchName: string) => void;
};
class BranchRoot extends React.Component<Props> {
componentDidMount() {
const { fetchBranch, repository, branchName } = this.props;
fetchBranch(repository, branchName);
type Params = {
branch: string;
};
const BranchRoot: FC<Props> = ({ repository }) => {
const match = useRouteMatch<Params>();
const { isLoading, error, data: branch } = useBranch(repository, match.params.branch);
const location = useLocation();
if (isLoading) {
return <Loading />;
}
render() {
const { repository, branch, loading, error, match, location } = this.props;
const url = urls.matchedUrl(this.props);
if (error) {
if (error instanceof NotFoundError && queryString.parse(location.search).create === "true") {
return (
<Redirect
to={`/repo/${repository.namespace}/${repository.name}/branches/create?name=${match.params.branch}`}
/>
);
}
return <ErrorNotification error={error} />;
if (error) {
if (error instanceof NotFoundError && queryString.parse(location.search).create === "true") {
return (
<Redirect to={`/repo/${repository.namespace}/${repository.name}/branches/create?name=${match.params.branch}`} />
);
}
if (loading || !branch) {
return <Loading />;
}
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`} component={() => <BranchView repository={repository} branch={branch} />} />
</Switch>
);
return <ErrorNotification error={error} />;
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository } = ownProps;
const branchName = decodeURIComponent(ownProps.match.params.branch);
const branch = getBranch(state, repository, branchName);
const loading = isFetchBranchPending(state, repository, branchName);
const error = getFetchBranchFailure(state, repository, branchName);
return {
repository,
branchName,
branch,
loading,
error
};
const url = urls.matchedUrlFromMatch(match);
if (!branch) {
return null;
}
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`}>
<BranchView repository={repository} branch={branch} />
</Route>
</Switch>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchBranch: (repository: Repository, branchName: string) => {
dispatch(fetchBranch(repository, branchName));
}
};
};
export default compose(withRouter, connect(mapStateToProps, mapDispatchToProps))(BranchRoot);
export default BranchRoot;

View File

@@ -21,130 +21,55 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { compose } from "redux";
import { withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { Branch, Repository } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { CreateButton, ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending,
isPermittedToCreateBranches
} from "../modules/branches";
import { orderBranches } from "../util/orderBranches";
import BranchTable from "../components/BranchTable";
import { useBranches } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
repository: Repository;
baseUrl: string;
loading: boolean;
error: Error;
branches: Branch[];
// dispatch props
showCreateButton: boolean;
fetchBranches: (p: Repository) => void;
// Context props
history: any;
match: any;
};
class BranchesOverview extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useBranches(repository);
const [t] = useTranslation("repos");
if (error) {
return <ErrorNotification error={error} />;
}
render() {
const { loading, error, branches, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!branches || loading) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{this.renderBranchesTable()}
{this.renderCreateButton()}
</>
);
if (!data || isLoading) {
return <Loading />;
}
renderBranchesTable() {
const { baseUrl, branches, repository, fetchBranches, t } = this.props;
if (branches && branches.length > 0) {
orderBranches(branches);
const staleBranches = branches.filter(b => b.stale);
const activeBranches = branches.filter(b => !b.stale);
return (
<>
{activeBranches.length > 0 && (
<BranchTable
baseUrl={baseUrl}
type={"active"}
branches={activeBranches}
fetchBranches={() => fetchBranches(repository)}
/>
)}
{staleBranches.length > 0 && (
<BranchTable
baseUrl={baseUrl}
type={"stale"}
branches={staleBranches}
fetchBranches={() => fetchBranches(repository)}
/>
)}
</>
);
}
const branches = data._embedded.branches || [];
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
renderCreateButton() {
const { showCreateButton, t } = this.props;
if (showCreateButton) {
return <CreateButton label={t("branches.overview.createButton")} link="./create" />;
}
return null;
}
}
orderBranches(branches);
const staleBranches = branches.filter(b => b.stale);
const activeBranches = branches.filter(b => !b.stale);
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository } = ownProps;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const showCreateButton = isPermittedToCreateBranches(state, repository);
const showCreateButton = !!data._links.create;
return {
repository,
loading,
error,
branches,
showCreateButton
};
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{activeBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="active" branches={activeBranches} />
) : null}
{staleBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="stale" branches={staleBranches} />
) : null}
{showCreateButton ? <CreateButton label={t("branches.overview.createButton")} link="./create" /> : null}
</>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
}
};
};
export default compose(
withTranslation("repos"),
withRouter,
connect(mapStateToProps, mapDispatchToProps)
)(BranchesOverview);
export default BranchesOverview;

View File

@@ -21,137 +21,62 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { Redirect, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import queryString from "query-string";
import { History } from "history";
import { Branch, BranchRequest, Repository } from "@scm-manager/ui-types";
import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
import BranchForm from "../components/BranchForm";
import {
createBranch,
createBranchReset,
fetchBranches,
getBranchCreateLink,
getBranches,
getCreateBranchFailure,
getFetchBranchesFailure,
isCreateBranchPending,
isFetchBranchesPending
} from "../modules/branches";
import { compose } from "redux";
import { useBranches, useCreateBranch } from "@scm-manager/ui-api";
type Props = WithTranslation & {
loading?: boolean;
error?: Error;
type Props = {
repository: Repository;
branches: Branch[];
createBranchesLink: string;
isPermittedToCreateBranches: boolean;
// dispatcher functions
fetchBranches: (p: Repository) => void;
createBranch: (
createLink: string,
repository: Repository,
branch: BranchRequest,
callback?: (p: Branch) => void
) => void;
resetForm: (p: Repository) => void;
// context objects
history: History;
location: any;
};
class CreateBranch extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
this.props.resetForm(repository);
}
const CreateBranch: FC<Props> = ({ repository }) => {
const { isLoading: isLoadingCreate, error: errorCreate, create, branch: createdBranch } = useCreateBranch(repository);
const { isLoading: isLoadingList, error: errorList, data: branches } = useBranches(repository);
const location = useLocation();
const [t] = useTranslation("repos");
branchCreated = (branch: Branch) => {
const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.name}/branch/${encodeURIComponent(branch.name)}/info`);
};
createBranch = (branch: BranchRequest) => {
this.props.createBranch(this.props.createBranchesLink, this.props.repository, branch, newBranch =>
this.branchCreated(newBranch)
);
};
transmittedName = (url: string) => {
const transmittedName = (url: string) => {
const params = queryString.parse(url);
return params.name;
};
render() {
const { t, loading, error, repository, branches, createBranchesLink, location } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading || !branches) {
return <Loading />;
}
if (createdBranch) {
return (
<>
<Subtitle subtitle={t("branches.create.title")} />
<BranchForm
submitForm={branchRequest => this.createBranch(branchRequest)}
loading={loading}
repository={repository}
branches={branches}
transmittedName={this.transmittedName(location.search)}
disabled={!createBranchesLink}
/>
</>
<Redirect
to={`/repo/${repository.namespace}/${repository.name}/branch/${encodeURIComponent(createdBranch.name)}/info`}
/>
);
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
},
createBranch: (
createLink: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (newBranch: Branch) => void
) => {
dispatch(createBranch(createLink, repository, branchRequest, callback));
},
resetForm: (repository: Repository) => {
dispatch(createBranchReset(repository));
}
};
if (errorList) {
return <ErrorNotification error={errorList} />;
}
if (errorCreate) {
return <ErrorNotification error={errorCreate} />;
}
if (isLoadingList || !branches) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.create.title")} />
<BranchForm
submitForm={create}
loading={isLoadingCreate}
repository={repository}
branches={branches._embedded.branches}
transmittedName={transmittedName(location.search)}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository } = ownProps;
const loading = isFetchBranchesPending(state, repository) || isCreateBranchPending(state, repository);
const error = getFetchBranchesFailure(state, repository) || getCreateBranchFailure(state, repository);
const branches = getBranches(state, repository);
const createBranchesLink = getBranchCreateLink(state, repository);
return {
repository,
loading,
error,
branches,
createBranchesLink
};
};
export default compose(
withTranslation("repos"),
connect(mapStateToProps, mapDispatchToProps),
withRouter
)(CreateBranch);
export default CreateBranch;

View File

@@ -1,519 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
CREATE_BRANCH,
CREATE_BRANCH_FAILURE,
CREATE_BRANCH_PENDING,
CREATE_BRANCH_SUCCESS,
createBranch,
FETCH_BRANCH_FAILURE,
FETCH_BRANCH_PENDING,
FETCH_BRANCH_SUCCESS,
FETCH_BRANCHES,
FETCH_BRANCHES_FAILURE,
FETCH_BRANCHES_PENDING,
FETCH_BRANCHES_SUCCESS,
fetchBranch,
fetchBranches,
fetchBranchSuccess,
getBranch,
getBranches,
getCreateBranchFailure,
getFetchBranchesFailure,
isCreateBranchPending,
isFetchBranchesPending,
isPermittedToCreateBranches
} from "./branches";
const namespace = "foo";
const name = "bar";
const key = namespace + "/" + name;
const repository = {
namespace: "foo",
name: "bar",
_links: {
branches: {
href: "http://scm/api/rest/v2/repositories/foo/bar/branches"
}
}
};
const branch1 = {
name: "branch1",
revision: "revision1"
};
const branch2 = {
name: "branch2",
revision: "revision2"
};
const branch3 = {
name: "branch3",
revision: "revision3"
};
const branchRequest = {
name: "newBranch",
source: "master"
};
const newBranch = {
name: "newBranch",
revision: "rev3"
};
describe("branches", () => {
describe("fetch branches", () => {
const URL = "http://scm/api/rest/v2/repositories/foo/bar/branches";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch branches", () => {
const collection = {};
fetchMock.getOnce(URL, "{}");
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: {
repository
},
itemId: key
},
{
type: FETCH_BRANCHES_SUCCESS,
payload: {
data: collection,
repository
},
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching branches on HTTP 500", () => {
const collection = {};
fetchMock.getOnce(URL, 500);
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: {
repository
},
itemId: key
},
{
type: FETCH_BRANCHES_FAILURE,
payload: {
error: collection,
repository
},
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_BRANCHES_FAILURE);
});
});
it("should successfully fetch single branch", () => {
fetchMock.getOnce(URL + "/branch1", branch1);
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch1")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single branch on HTTP 500", () => {
fetchMock.getOnce(URL + "/branch2", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch2")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should create a branch successfully", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
return store.dispatch(createBranch(URL, repository, branchRequest)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_SUCCESS);
});
});
it("should call the callback with the branch from the location header", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
let receivedBranch = null;
const callback = branch => {
receivedBranch = branch;
};
return store.dispatch(createBranch(URL, repository, branchRequest, callback)).then(() => {
expect(receivedBranch).toEqual(newBranch);
});
});
it("should fail creating a branch on HTTP 500", () => {
fetchMock.postOnce(URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createBranch(URL, repository, branchRequest)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_FAILURE);
});
});
});
describe("branches reducer", () => {
const branches = {
_embedded: {
branches: [branch1, branch2]
},
_links: {
self: {
href: "/self"
},
create: {
href: "/create"
}
}
};
const action = {
type: FETCH_BRANCHES_SUCCESS,
payload: {
repository,
data: branches
}
};
it("should store the branches", () => {
const newState = reducer({}, action);
const repoState = newState["foo/bar"];
expect(repoState.list._links.create.href).toEqual("/create");
expect(repoState.list._embedded.branches).toEqual(["branch1", "branch2"]);
expect(repoState.byName.branch1).toEqual(branch1);
expect(repoState.byName.branch2).toEqual(branch2);
});
it("should store a single branch", () => {
const newState = reducer({}, fetchBranchSuccess(repository, branch1));
const repoState = newState["foo/bar"];
expect(repoState.list).toBeUndefined();
expect(repoState.byName.branch1).toEqual(branch1);
});
it("should add a single branch", () => {
const state = {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1"]
}
},
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch2));
const repoState = newState["foo/bar"];
const byName = repoState.byName;
expect(repoState.list._embedded.branches).toEqual(["branch1"]);
expect(byName.branch1).toEqual(branch1);
expect(byName.branch2).toEqual(branch2);
});
it("should not overwrite non related repositories", () => {
const state = {
"scm/core": {
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["scm/core"].byName;
expect(byName.branch1).toEqual(branch1);
});
it("should overwrite existing branch", () => {
const state = {
"foo/bar": {
byName: {
branch1: {
name: "branch1",
revision: "xyz"
}
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["foo/bar"].byName;
expect(byName.branch1.revision).toEqual("revision1");
});
it("should not overwrite existing branches", () => {
const state = {
"foo/bar": {
byName: {
branch1,
branch2,
branch3
}
}
};
const newState = reducer(state, action);
expect(newState["foo/bar"].byName.branch1).toEqual(branch1);
expect(newState["foo/bar"].byName.branch2).toEqual(branch2);
expect(newState["foo/bar"].byName.branch3).toEqual(branch3);
});
});
describe("branch selectors", () => {
const error = new Error("Something went wrong");
const state = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1", "branch2"]
}
},
byName: {
branch1: branch1,
branch2: branch2
}
}
}
};
it("should return true, when fetching branches is pending", () => {
const state = {
pending: {
[FETCH_BRANCHES + "/foo/bar"]: true
}
};
expect(isFetchBranchesPending(state, repository)).toBeTruthy();
});
it("should return branches", () => {
const branches = getBranches(state, repository);
expect(branches.length).toEqual(2);
expect(branches).toContain(branch1);
expect(branches).toContain(branch2);
});
it("should return always the same reference for branches", () => {
const one = getBranches(state, repository);
const two = getBranches(state, repository);
expect(one).toBe(two);
});
it("should not return cached reference, if branches have changed", () => {
const one = getBranches(state, repository);
const newState = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch2", "branch3"]
}
},
byName: {
branch2,
branch3
}
}
}
};
const two = getBranches(newState, repository);
expect(one).not.toBe(two);
expect(two).not.toContain(branch1);
expect(two).toContain(branch2);
expect(two).toContain(branch3);
});
it("should return undefined, if no branches for the repository available", () => {
const branches = getBranches(
{
branches: {}
},
repository
);
expect(branches).toBeUndefined();
});
it("should return single branch by name", () => {
const branch = getBranch(state, repository, "branch1");
expect(branch).toEqual(branch1);
});
it("should return same reference for single branch by name", () => {
const one = getBranch(state, repository, "branch1");
const two = getBranch(state, repository, "branch1");
expect(one).toBe(two);
});
it("should return undefined if branch does not exist", () => {
const branch = getBranch(state, repository, "branch42");
expect(branch).toBeUndefined();
});
it("should return true if the branches list contains the create link", () => {
const stateWithLink = {
branches: {
"foo/bar": {
...state.branches["foo/bar"],
list: {
...state.branches["foo/bar"].list,
_links: {
create: {
href: "http://create-it"
}
}
}
}
}
};
const permitted = isPermittedToCreateBranches(stateWithLink, repository);
expect(permitted).toBe(true);
});
it("should return false if the create link is missing", () => {
const permitted = isPermittedToCreateBranches(state, repository);
expect(permitted).toBe(false);
});
it("should return error if fetching branches failed", () => {
const state = {
failure: {
[FETCH_BRANCHES + "/foo/bar"]: error
}
};
expect(getFetchBranchesFailure(state, repository)).toEqual(error);
});
it("should return false if fetching branches did not fail", () => {
expect(getFetchBranchesFailure({}, repository)).toBeUndefined();
});
it("should return true if create branch is pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: true
}
};
expect(isCreateBranchPending(state, repository)).toBe(true);
});
it("should return false if create branch is not pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: false
}
};
expect(isCreateBranchPending(state, repository)).toBe(false);
});
it("should return error when create branch did fail", () => {
const state = {
failure: {
[CREATE_BRANCH + `/${repository.namespace}/${repository.name}`]: error
}
};
expect(getCreateBranchFailure(state, repository)).toEqual(error);
});
it("should return undefined when create branch did not fail", () => {
expect(getCreateBranchFailure({}, repository)).toBe(undefined);
});
});
});

View File

@@ -1,371 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { FAILURE_SUFFIX, PENDING_SUFFIX, RESET_SUFFIX, SUCCESS_SUFFIX } from "../../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import { Action, Branch, BranchRequest, Repository, Link } from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import memoizeOne from "memoize-one";
export const FETCH_BRANCHES = "scm/repos/FETCH_BRANCHES";
export const FETCH_BRANCHES_PENDING = `${FETCH_BRANCHES}_${PENDING_SUFFIX}`;
export const FETCH_BRANCHES_SUCCESS = `${FETCH_BRANCHES}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCHES_FAILURE = `${FETCH_BRANCHES}_${FAILURE_SUFFIX}`;
export const FETCH_BRANCH = "scm/repos/FETCH_BRANCH";
export const FETCH_BRANCH_PENDING = `${FETCH_BRANCH}_${PENDING_SUFFIX}`;
export const FETCH_BRANCH_SUCCESS = `${FETCH_BRANCH}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCH_FAILURE = `${FETCH_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH = "scm/repos/CREATE_BRANCH";
export const CREATE_BRANCH_PENDING = `${CREATE_BRANCH}_${PENDING_SUFFIX}`;
export const CREATE_BRANCH_SUCCESS = `${CREATE_BRANCH}_${SUCCESS_SUFFIX}`;
export const CREATE_BRANCH_FAILURE = `${CREATE_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH_RESET = `${CREATE_BRANCH}_${RESET_SUFFIX}`;
const CONTENT_TYPE_BRANCH_REQUEST = "application/vnd.scmm-branchRequest+json;v=2";
// Fetching branches
export function fetchBranches(repository: Repository) {
if (!repository._links.branches) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: {
repository,
data: {}
},
itemId: createKey(repository)
};
}
return function(dispatch: any) {
dispatch(fetchBranchesPending(repository));
return apiClient
.get((repository._links.branches as Link).href)
.then(response => response.json())
.then(data => {
dispatch(fetchBranchesSuccess(data, repository));
})
.catch(error => {
dispatch(fetchBranchesFailure(repository, error));
});
};
}
export function fetchBranch(repository: Repository, name: string) {
let link = (repository._links.branches as Link).href;
if (!link.endsWith("/")) {
link += "/";
}
link += encodeURIComponent(name);
return function(dispatch: any) {
dispatch(fetchBranchPending(repository, name));
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchBranchSuccess(repository, data));
})
.catch(error => {
dispatch(fetchBranchFailure(repository, name, error));
});
};
}
// create branch
export function createBranch(
link: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (branch: Branch) => void
) {
return function(dispatch: any) {
dispatch(createBranchPending(repository));
return apiClient
.post(link, branchRequest, CONTENT_TYPE_BRANCH_REQUEST)
.then(response => response.headers.get("Location"))
.then(location => apiClient.get(location))
.then(response => response.json())
.then(branch => {
dispatch(createBranchSuccess(repository));
if (callback) {
callback(branch);
}
})
.catch(error => dispatch(createBranchFailure(repository, error)));
};
}
// Selectors
function collectBranches(repoState) {
return repoState.list._embedded.branches.map(name => repoState.byName[name]);
}
const memoizedBranchCollector = memoizeOne(collectBranches);
export function getBranches(state: object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (repoState && repoState.list) {
return memoizedBranchCollector(repoState);
}
}
export function getBranchCreateLink(state: object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (repoState && repoState.list && repoState.list._links && repoState.list._links.create) {
return repoState.list._links.create.href;
}
}
function getRepoState(state: object, repository: Repository) {
const key = createKey(repository);
const repoState = state.branches[key];
if (repoState && repoState.byName) {
return repoState;
}
}
export const isPermittedToCreateBranches = (state: object, repository: Repository): boolean => {
const repoState = getRepoState(state, repository);
return !!(repoState && repoState.list && repoState.list._links && repoState.list._links.create);
};
export function getBranch(state: object, repository: Repository, name: string): Branch | null | undefined {
const repoState = getRepoState(state, repository);
if (repoState) {
return repoState.byName[name];
}
}
// Action creators
export function isFetchBranchesPending(state: object, repository: Repository): boolean {
return isPending(state, FETCH_BRANCHES, createKey(repository));
}
export function getFetchBranchesFailure(state: object, repository: Repository) {
return getFailure(state, FETCH_BRANCHES, createKey(repository));
}
export function fetchBranchesPending(repository: Repository) {
return {
type: FETCH_BRANCHES_PENDING,
payload: {
repository
},
itemId: createKey(repository)
};
}
export function fetchBranchesSuccess(data: string, repository: Repository) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: {
data,
repository
},
itemId: createKey(repository)
};
}
export function fetchBranchesFailure(repository: Repository, error: Error) {
return {
type: FETCH_BRANCHES_FAILURE,
payload: {
error,
repository
},
itemId: createKey(repository)
};
}
export function isCreateBranchPending(state: object, repository: Repository) {
return isPending(state, CREATE_BRANCH, createKey(repository));
}
export function getCreateBranchFailure(state: object, repository: Repository) {
return getFailure(state, CREATE_BRANCH, createKey(repository));
}
export function createBranchPending(repository: Repository): Action {
return {
type: CREATE_BRANCH_PENDING,
payload: {
repository
},
itemId: createKey(repository)
};
}
export function createBranchSuccess(repository: Repository): Action {
return {
type: CREATE_BRANCH_SUCCESS,
payload: {
repository
},
itemId: createKey(repository)
};
}
export function createBranchFailure(repository: Repository, error: Error): Action {
return {
type: CREATE_BRANCH_FAILURE,
payload: {
repository,
error
},
itemId: createKey(repository)
};
}
export function createBranchReset(repository: Repository): Action {
return {
type: CREATE_BRANCH_RESET,
payload: {
repository
},
itemId: createKey(repository)
};
}
export function isFetchBranchPending(state: object, repository: Repository, name: string) {
return isPending(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function getFetchBranchFailure(state: object, repository: Repository, name: string) {
return getFailure(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function fetchBranchPending(repository: Repository, name: string): Action {
return {
type: FETCH_BRANCH_PENDING,
payload: {
repository,
name
},
itemId: createKey(repository) + "/" + name
};
}
export function fetchBranchSuccess(repository: Repository, branch: Branch): Action {
return {
type: FETCH_BRANCH_SUCCESS,
payload: {
repository,
branch
},
itemId: createKey(repository) + "/" + branch.name
};
}
export function fetchBranchFailure(repository: Repository, name: string, error: Error): Action {
return {
type: FETCH_BRANCH_FAILURE,
payload: {
error,
repository,
name
},
itemId: createKey(repository) + "/" + name
};
}
// Reducers
const reduceByBranchesSuccess = (state, payload) => {
const repository = payload.repository;
const response = payload.data;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
repoState.byName = byName;
if (response._embedded) {
const branches = response._embedded.branches;
response._embedded.branches = branches.map(b => b.name);
for (const branch of branches) {
byName[branch.name] = branch;
}
return {
[key]: {
list: response,
byName
}
};
}
return {
[key]: []
};
};
const reduceByBranchSuccess = (state, payload) => {
const repository = payload.repository;
const branch = payload.branch;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
byName[branch.name] = branch;
repoState.byName = byName;
return {
...state,
[key]: repoState
};
};
export default function reducer(
state: {} = {},
action: Action = {
type: "UNKNOWN"
}
): object {
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_BRANCHES_SUCCESS:
return reduceByBranchesSuccess(state, payload);
case FETCH_BRANCH_SUCCESS:
return reduceByBranchSuccess(state, payload);
default:
return state;
}
}
function createKey(repository: Repository): string {
const { namespace, name } = repository;
return `${namespace}/${name}`;
}

View File

@@ -50,7 +50,7 @@ const FlexShrinkLevel = styled(Level)`
type Props = {
selectedBranch?: string;
branches: Branch[];
branches?: Branch[];
onSelectBranch: () => void;
switchViewLink: string;
};
@@ -63,7 +63,7 @@ const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, sw
<ActionBar>
<FlexShrinkLevel
left={
branches?.length > 0 && (
branches && branches?.length > 0 && (
<BranchSelector
label={t("code.branchSelector")}
branches={branches}

View File

@@ -44,37 +44,26 @@ type Props = {
const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => {
const { t } = useTranslation("repos");
const resolveLocation = () => {
if (currentUrl.includes("/code/branch") || currentUrl.includes("/code/changesets")) {
return "changesets";
}
if (currentUrl.includes("/code/sources")) {
return "sources";
}
return "";
};
const isSourcesTab = () => {
return resolveLocation() === "sources";
};
const isChangesetsTab = () => {
return resolveLocation() === "changesets";
};
let location = "";
if (currentUrl.includes("/code/branch") || currentUrl.includes("/code/changesets")) {
location = "changesets";
} else if (currentUrl.includes("/code/sources")) {
location = "sources";
}
return (
<ButtonAddonsMarginRight>
<SmallButton
label={t("code.commits")}
icon="fa fa-exchange-alt"
color={isChangesetsTab() ? "link is-selected" : undefined}
link={isSourcesTab() ? switchViewLink : undefined}
color={location === "changesets" ? "link is-selected" : undefined}
link={location === "sources" ? switchViewLink : undefined}
/>
<SmallButton
label={t("code.sources")}
icon="fa fa-code"
color={isSourcesTab() ? "link is-selected" : undefined}
link={isChangesetsTab() ? switchViewLink : undefined}
color={location === "sources" ? "link is-selected" : undefined}
link={location === "changesets" ? switchViewLink : undefined}
/>
</ButtonAddonsMarginRight>
);

View File

@@ -21,116 +21,79 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { Route, RouteComponentProps, withRouter } from "react-router-dom";
import React, { FC } from "react";
import { Route, useLocation } from "react-router-dom";
import Sources from "../../sources/containers/Sources";
import ChangesetsRoot from "../../containers/ChangesetsRoot";
import { Branch, Repository } from "@scm-manager/ui-types";
import { ErrorPage, Loading } from "@scm-manager/ui-components";
import { compose } from "redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { connect } from "react-redux";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending
} from "../../branches/modules/branches";
import { useTranslation } from "react-i18next";
import { useBranches } from "@scm-manager/ui-api";
type Props = RouteComponentProps &
WithTranslation & {
repository: Repository;
baseUrl: string;
// State props
branches: Branch[];
error: Error;
loading: boolean;
selectedBranch: string;
// Dispatch props
fetchBranches: (p: Repository) => void;
};
class CodeOverview extends React.Component<Props> {
componentDidMount() {
const { repository } = this.props;
this.props.fetchBranches(repository);
}
render() {
const { repository, baseUrl, branches, selectedBranch, error, loading, t } = this.props;
const url = baseUrl;
if (loading) {
return <Loading />;
}
if (error) {
return (
<ErrorPage title={t("repositoryRoot.errorTitle")} subtitle={t("repositoryRoot.errorSubtitle")} error={error} />
);
}
return (
<>
<Route
path={`${url}/sources`}
exact={true}
render={() => <Sources repository={repository} baseUrl={`${url}`} branches={branches} />}
/>
<Route
path={`${url}/sources/:revision/:path*`}
render={() => (
<Sources repository={repository} baseUrl={`${url}`} branches={branches} selectedBranch={selectedBranch} />
)}
/>
<Route
path={`${url}/changesets`}
render={() => <ChangesetsRoot repository={repository} baseUrl={`${url}`} branches={branches} />}
/>
<Route
path={`${url}/branch/:branch/changesets/`}
render={() => (
<ChangesetsRoot
repository={repository}
baseUrl={`${url}`}
branches={branches}
selectedBranch={selectedBranch}
/>
)}
/>
</>
);
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchBranches: (repo: Repository) => {
dispatch(fetchBranches(repo));
}
};
type Props = {
repository: Repository;
baseUrl: string;
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, location } = ownProps;
const error = getFetchBranchesFailure(state, repository);
const loading = isFetchBranchesPending(state, repository);
const branches = getBranches(state, repository);
const useSelectedBranch = () => {
const location = useLocation();
const branchFromURL =
!location.pathname.includes("/code/changesets/") && decodeURIComponent(location.pathname.split("/")[6]);
const selectedBranch = branchFromURL && branchFromURL !== "undefined" ? branchFromURL : "";
return {
error,
loading,
branches,
selectedBranch
};
return branchFromURL && branchFromURL !== "undefined" ? branchFromURL : "";
};
export default compose(
withRouter,
withTranslation("repos"),
connect(mapStateToProps, mapDispatchToProps)
)(CodeOverview);
const supportBranches = (repository: Repository) => {
return !!repository._links.branches;
};
const CodeOverview: FC<Props> = ({ baseUrl, repository }) => {
if (supportBranches(repository)) {
return <CodeOverviewWithBranches baseUrl={baseUrl} repository={repository} />;
}
return <CodeRouting baseUrl={baseUrl} repository={repository} />;
};
const CodeOverviewWithBranches: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useBranches(repository);
const selectedBranch = useSelectedBranch();
const [t] = useTranslation("repos");
const branches = data?._embedded.branches;
if (isLoading) {
return <Loading />;
}
if (error) {
return (
<ErrorPage title={t("repositoryRoot.errorTitle")} subtitle={t("repositoryRoot.errorSubtitle")} error={error} />
);
}
return <CodeRouting repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />;
};
type RoutingProps = {
repository: Repository;
baseUrl: string;
branches?: Branch[];
selectedBranch?: string;
};
const CodeRouting: FC<RoutingProps> = ({ repository, baseUrl, branches, selectedBranch }) => (
<>
<Route path={`${baseUrl}/sources`} exact={true}>
<Sources repository={repository} baseUrl={baseUrl} branches={branches} />
</Route>
<Route path={`${baseUrl}/sources/:revision/:path*`}>
<Sources repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
</Route>
<Route path={`${baseUrl}/changesets`}>
<ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} />
</Route>
<Route path={`${baseUrl}/branch/:branch/changesets/`}>
<ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
</Route>
</>
);
export default CodeOverview;

View File

@@ -21,28 +21,35 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Repository } from "@scm-manager/ui-types";
import { CUSTOM_NAMESPACE_STRATEGY } from "../modules/repos";
import { Repository, CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import { InputField } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import * as validator from "./form/repositoryValidation";
import { getNamespaceStrategies } from "../../admin/modules/namespaceStrategies";
import { connect } from "react-redux";
import { useNamespaceStrategies } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
onChange: (repository: Repository) => void;
namespaceStrategy: string;
setValid: (valid: boolean) => void;
disabled?: boolean;
};
const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, namespaceStrategy, setValid, disabled }) => {
const [t] = useTranslation("repos");
const [namespaceValidationError, setNamespaceValidationError] = useState(false);
const useNamespaceStrategy = () => {
const { data } = useNamespaceStrategies();
if (data) {
return data.current;
}
return "";
};
const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, disabled }) => {
const namespaceStrategy = useNamespaceStrategy();
const [nameValidationError, setNameValidationError] = useState(false);
const [namespaceValidationError, setNamespaceValidationError] = useState(false);
const [t] = useTranslation("repos");
useEffect(() => {
if (repository.name) {
@@ -93,6 +100,11 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, namespaceStra
return <ExtensionPoint name="repos.create.namespace" props={props} renderAll={false} />;
};
// not yet loaded
if (namespaceStrategy === "") {
return null;
}
return (
<>
{renderNamespaceField()}
@@ -109,11 +121,4 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, namespaceStra
);
};
const mapStateToProps = (state: any) => {
const namespaceStrategy = getNamespaceStrategies(state).current;
return {
namespaceStrategy
};
};
export default connect(mapStateToProps)(NamespaceAndNameFields);
export default NamespaceAndNameFields;

View File

@@ -22,11 +22,11 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Changeset, Link, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
import { Changeset, Link, ParentChangeset, Repository } from "@scm-manager/ui-types";
import {
AvatarImage,
AvatarWrapper,
@@ -42,18 +42,16 @@ import {
Icon,
Level,
SignatureIcon,
Tooltip,
ErrorNotification,
CreateTagModal
Tooltip
} from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom";
import CreateTagModal from "./CreateTagModal";
type Props = WithTranslation & {
type Props = {
changeset: Changeset;
repository: Repository;
fileControlFactory?: FileControlFactory;
refetchChangeset?: () => void;
};
const RightMarginP = styled.p`
@@ -112,6 +110,7 @@ const ChangesetSummary = styled.div`
const SeparatedParents = styled.div`
margin-left: 1em;
a + a:before {
content: ",\\00A0";
color: #4a4a4a;
@@ -158,15 +157,15 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
);
};
const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory, t, refetchChangeset }) => {
const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory }) => {
const [collapsed, setCollapsed] = useState(false);
const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [t] = useTranslation("repos");
const description = changesets.parseDescription(changeset.description);
const id = <ChangesetId repository={repository} changeset={changeset} link={false} />;
const date = <DateFromNow date={changeset.date} />;
const parents = changeset._embedded.parents.map((parent: ParentChangeset, index: number) => (
const parents = changeset._embedded.parents?.map((parent: ParentChangeset, index: number) => (
<ReactLink title={parent.id} to={parent.id} key={index}>
{parent.id.substring(0, 7)}
</ReactLink>
@@ -177,10 +176,6 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
setCollapsed(!collapsed);
};
if (error) {
return <ErrorNotification error={error} />;
}
return (
<>
<div className={classNames("content", "is-marginless")}>
@@ -208,12 +203,12 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
<p>
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
</p>
{parents?.length > 0 && (
{parents && parents?.length > 0 ? (
<SeparatedParents>
{t("changeset.parents.label", { count: parents?.length }) + ": "}
{parents}
</SeparatedParents>
)}
) : null}
</ChangesetSummary>
</div>
<div className="media-right">
@@ -235,15 +230,9 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
)}
{isTagCreationModalVisible && (
<CreateTagModal
revision={changeset.id}
repository={repository}
changeset={changeset}
onClose={() => setTagCreationModalVisible(false)}
onCreated={() => {
refetchChangeset?.();
setTagCreationModalVisible(false);
}}
onError={setError}
tagCreationLink={(changeset._links["tag"] as Link).href}
existingTagsLink={(repository._links["tags"] as Link).href}
/>
)}
</article>
@@ -285,4 +274,4 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
);
};
export default withTranslation("repos")(ChangesetDetails);
export default ChangesetDetails;

View File

@@ -0,0 +1,110 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Modal, InputField, Button, Loading, ErrorNotification } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { Changeset, Repository } from "@scm-manager/ui-types";
import { useCreateTag, useTags } from "@scm-manager/ui-api";
import { validation } from "@scm-manager/ui-components/";
type Props = {
changeset: Changeset;
repository: Repository;
onClose: () => void;
};
const CreateTagModal: FC<Props> = ({ repository, changeset, onClose }) => {
const { isLoading, error, data: tags } = useTags(repository);
const { isLoading: isLoadingCreate, error: errorCreate, create, tag: createdTag } = useCreateTag(
repository,
changeset
);
const [t] = useTranslation("repos");
const [newTagName, setNewTagName] = useState("");
useEffect(() => {
if (createdTag) {
onClose();
}
}, [createdTag, onClose]);
const tagNames = tags?._embedded.tags.map(tag => tag.name);
let validationError = "";
if (tagNames !== undefined && newTagName !== "") {
if (tagNames.includes(newTagName)) {
validationError = "tags.create.form.field.name.error.exists";
} else if (!validation.isBranchValid(newTagName)) {
validationError = "tags.create.form.field.name.error.format";
}
}
let body;
if (isLoading) {
body = <Loading />;
} else if (error) {
body = <ErrorNotification error={error} />;
} else if (errorCreate) {
body = <ErrorNotification error={errorCreate} />;
} else {
body = (
<>
<InputField
name="name"
label={t("tags.create.form.field.name.label")}
onChange={val => setNewTagName(val)}
value={newTagName}
validationError={!!validationError}
errorMessage={t(validationError)}
/>
<div className="mt-6">{t("tags.create.hint")}</div>
</>
);
}
return (
<Modal
title={t("tags.create.title")}
active={true}
body={body}
footer={
<>
<Button action={onClose}>{t("tags.create.cancel")}</Button>
<Button
color="success"
action={() => create(newTagName)}
loading={isLoadingCreate}
disabled={isLoading || isLoadingCreate || !!validationError || newTagName.length === 0}
>
{t("tags.create.confirm")}
</Button>
</>
}
closeFunction={onClose}
/>
);
};
export default CreateTagModal;

View File

@@ -25,9 +25,8 @@ import React, { FC, useEffect, useState } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Repository, RepositoryType } from "@scm-manager/ui-types";
import { IndexResources, Repository, RepositoryType, CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
import { Checkbox, Level, Select, SubmitButton } from "@scm-manager/ui-components";
import { CUSTOM_NAMESPACE_STRATEGY } from "../../modules/repos";
import NamespaceAndNameFields from "../NamespaceAndNameFields";
import RepositoryInformationForm from "../RepositoryInformationForm";
@@ -52,7 +51,7 @@ type Props = {
repositoryTypes?: RepositoryType[];
namespaceStrategy?: string;
loading: boolean;
indexResources?: any;
indexResources?: IndexResources;
};
type RepositoryCreation = Repository & {

View File

@@ -22,46 +22,31 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { Button, ConfirmAlert, ErrorNotification, Level } from "@scm-manager/ui-components";
import { archiveRepo, getModifyRepoFailure, isModifyRepoPending } from "../modules/repos";
import { useArchiveRepository } from "@scm-manager/ui-api";
type Props = {
loading: boolean;
error: Error;
repository: Repository;
confirmDialog?: boolean;
archiveRepo: (p1: Repository, p2: () => void) => void;
};
const ArchiveRepo: FC<Props> = ({ confirmDialog = true, repository, archiveRepo, loading, error }: Props) => {
const ArchiveRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
const { isLoading, error, archive } = useArchiveRepository();
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const archived = () => {
window.location.reload();
};
const archiveRepoCallback = () => {
archiveRepo(repository, archived);
archive(repository);
};
const confirmArchive = () => {
setShowConfirmAlert(true);
};
const isArchiveable = () => {
return repository._links.archive;
};
const action = confirmDialog ? confirmArchive : archiveRepoCallback;
if (!isArchiveable()) {
return null;
}
const confirmAlert = (
<ConfirmAlert
title={t("archiveRepo.confirmAlert.title")}
@@ -70,12 +55,12 @@ const ArchiveRepo: FC<Props> = ({ confirmDialog = true, repository, archiveRepo,
{
className: "is-outlined",
label: t("archiveRepo.confirmAlert.submit"),
onClick: () => archiveRepoCallback(),
onClick: () => archiveRepoCallback()
},
{
label: t("archiveRepo.confirmAlert.cancel"),
onClick: () => null,
},
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
@@ -94,29 +79,11 @@ const ArchiveRepo: FC<Props> = ({ confirmDialog = true, repository, archiveRepo,
</p>
}
right={
<Button color="warning" icon="archive" label={t("archiveRepo.button")} action={action} loading={loading} />
<Button color="warning" icon="archive" label={t("archiveRepo.button")} action={action} loading={isLoading} />
}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
loading,
error,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
archiveRepo: (repo: Repository, callback: () => void) => {
dispatch(archiveRepo(repo, callback));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ArchiveRepo);
export default ArchiveRepo;

View File

@@ -21,95 +21,44 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { compose } from "redux";
import { withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Changeset, Repository } from "@scm-manager/ui-types";
import { ErrorPage, Loading } from "@scm-manager/ui-components";
import {
fetchChangeset,
fetchChangesetIfNeeded,
getChangeset,
getFetchChangesetFailure,
isFetchChangesetPending
} from "../modules/changesets";
import ChangesetDetails from "../components/changesets/ChangesetDetails";
import { FileControlFactory } from "@scm-manager/ui-components";
import { useChangeset } from "@scm-manager/ui-api";
import { useParams } from "react-router-dom";
type Props = WithTranslation & {
id: string;
changeset: Changeset;
type Props = {
repository: Repository;
fileControlFactoryFactory?: (changeset: Changeset) => FileControlFactory;
loading: boolean;
error: Error;
fetchChangesetIfNeeded: (repository: Repository, id: string) => void;
refetchChangeset: (repository: Repository, id: string) => void;
match: any;
};
class ChangesetView extends React.Component<Props> {
componentDidMount() {
const { fetchChangesetIfNeeded, repository, id } = this.props;
fetchChangesetIfNeeded(repository, id);
}
componentDidUpdate(prevProps: Props) {
const { fetchChangesetIfNeeded, repository, id } = this.props;
if (prevProps.id !== id) {
fetchChangesetIfNeeded(repository, id);
}
}
render() {
const { changeset, loading, error, t, repository, fileControlFactoryFactory, refetchChangeset } = this.props;
if (error) {
return <ErrorPage title={t("changesets.errorTitle")} subtitle={t("changesets.errorSubtitle")} error={error} />;
}
if (!changeset || loading) return <Loading />;
return (
<ChangesetDetails
changeset={changeset}
repository={repository}
fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)}
refetchChangeset={() => refetchChangeset(repository, changeset.id)}
/>
);
}
}
const mapStateToProps = (state: any, 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 {
id,
changeset,
error,
loading
};
type Params = {
id: string;
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchChangesetIfNeeded: (repository: Repository, id: string) => {
dispatch(fetchChangesetIfNeeded(repository, id));
},
refetchChangeset: (repository: Repository, id: string) => {
dispatch(fetchChangeset(repository, id));
}
};
const ChangesetView: FC<Props> = ({ repository, fileControlFactoryFactory }) => {
const { id } = useParams<Params>();
const { isLoading, error, data: changeset } = useChangeset(repository, id);
const [t] = useTranslation("repos");
if (error) {
return <ErrorPage title={t("changesets.errorTitle")} subtitle={t("changesets.errorSubtitle")} error={error} />;
}
if (!changeset || isLoading) {
return <Loading />;
}
return (
<ChangesetDetails
changeset={changeset}
repository={repository}
fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)}
/>
);
};
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
withTranslation("repos")
)(ChangesetView);
export default ChangesetView;

View File

@@ -21,127 +21,62 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { compose } from "redux";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { Branch, Changeset, PagedCollection, Repository } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Branch, Repository } from "@scm-manager/ui-types";
import {
ChangesetList,
ErrorNotification,
getPageFromMatch,
urls,
LinkPaginator,
Loading,
Notification
} from "@scm-manager/ui-components";
import {
fetchChangesets,
getChangesets,
getFetchChangesetsFailure,
isFetchChangesetsPending,
selectListAsCollection
} from "../modules/changesets";
import { useChangesets } from "@scm-manager/ui-api";
type Props = RouteComponentProps &
WithTranslation & {
repository: Repository;
branch: Branch;
page: number;
type Props = {
repository: Repository;
branch?: Branch;
};
// State props
changesets: Changeset[];
list: PagedCollection;
loading: boolean;
error: Error;
const usePage = () => {
const match = useRouteMatch();
return urls.getPageFromMatch(match);
};
// Dispatch props
fetchChangesets: (p1: Repository, p2: Branch, p3: number) => void;
};
const Changesets: FC<Props> = ({ repository, branch }) => {
const page = usePage();
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 });
const [t] = useTranslation("repos");
const changesets = data?._embedded.changesets;
class Changesets extends React.Component<Props> {
componentDidMount() {
const { fetchChangesets, repository, branch, page } = this.props;
fetchChangesets(repository, branch, page);
if (error) {
return <ErrorNotification error={error} />;
}
shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
return this.props.changesets !== nextProps.changesets ||
this.props.loading !== nextProps.loading ||
this.props.error !== nextProps.error;
if (isLoading) {
return <Loading />;
}
render() {
const { changesets, loading, error, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!changesets || changesets.length === 0) {
return (
<div className="panel-block">
<Notification type="info">{t("changesets.noChangesets")}</Notification>
</div>
);
}
if (!data || !changesets || changesets.length === 0) {
return (
<>
{this.renderList()}
{this.renderPaginator()}
</>
<div className="panel-block">
<Notification type="info">{t("changesets.noChangesets")}</Notification>
</div>
);
}
renderList = () => {
const { repository, changesets } = this.props;
return (
return (
<>
<div className="panel-block">
<ChangesetList repository={repository} changesets={changesets} />
</div>
);
};
renderPaginator = () => {
const { page, list } = this.props;
if (list) {
return (
<div className="panel-footer">
<LinkPaginator page={page} collection={list} />
</div>
);
}
return null;
};
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchChangesets: (repo: Repository, branch: Branch, page: number) => {
dispatch(fetchChangesets(repo, branch, page));
}
};
<div className="panel-footer">
<LinkPaginator page={page} collection={data} />
</div>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, branch, match } = ownProps;
const changesets = getChangesets(state, repository, branch);
const loading = isFetchChangesetsPending(state, repository, branch);
const error = getFetchChangesetsFailure(state, repository, branch);
const list = selectListAsCollection(state, repository, branch);
const page = getPageFromMatch(match);
return {
changesets,
list,
page,
loading,
error
};
};
export default compose(withTranslation("repos"), withRouter, connect(mapStateToProps, mapDispatchToProps))(Changesets);
export default Changesets;

View File

@@ -21,39 +21,43 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { Route, withRouter, RouteComponentProps } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { Route, useRouteMatch, useHistory } from "react-router-dom";
import { Repository, Branch } from "@scm-manager/ui-types";
import Changesets from "./Changesets";
import { compose } from "redux";
import CodeActionBar from "../codeSection/components/CodeActionBar";
import { urls } from "@scm-manager/ui-components";
type Props = WithTranslation &
RouteComponentProps & {
repository: Repository;
selectedBranch: string;
baseUrl: string;
branches: Branch[];
};
type Props = {
repository: Repository;
baseUrl: string;
branches?: Branch[];
selectedBranch?: string;
};
class ChangesetsRoot extends React.Component<Props> {
isBranchAvailable = () => {
const { branches, selectedBranch } = this.props;
const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }) => {
const match = useRouteMatch();
const history = useHistory();
if (!repository) {
return null;
}
const url = urls.stripEndingSlash(match.url);
const defaultBranch = branches?.find(b => b.defaultBranch === true);
const isBranchAvailable = () => {
return branches?.filter(b => b.name === selectedBranch).length === 0;
};
evaluateSwitchViewLink = () => {
const { baseUrl, selectedBranch } = this.props;
const evaluateSwitchViewLink = () => {
if (selectedBranch) {
return `${baseUrl}/sources/${encodeURIComponent(selectedBranch)}/`;
}
return `${baseUrl}/sources/`;
};
onSelectBranch = (branch?: Branch) => {
const { baseUrl, history } = this.props;
const onSelectBranch = (branch?: Branch) => {
if (branch) {
const url = `${baseUrl}/branch/${encodeURIComponent(branch.name)}/changesets/`;
history.push(url);
@@ -62,35 +66,21 @@ class ChangesetsRoot extends React.Component<Props> {
}
};
render() {
const { repository, branches, match, selectedBranch } = this.props;
return (
<>
<CodeActionBar
branches={branches}
selectedBranch={!isBranchAvailable() ? selectedBranch : defaultBranch?.name}
onSelectBranch={onSelectBranch}
switchViewLink={evaluateSwitchViewLink()}
/>
<div className="panel">
<Route path={`${url}/:page?`}>
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} />
</Route>
</div>
</>
);
};
if (!repository) {
return null;
}
const url = urls.stripEndingSlash(match.url);
const defaultBranch = branches?.find(b => b.defaultBranch === true);
return (
<>
<CodeActionBar
branches={branches}
selectedBranch={!this.isBranchAvailable() ? selectedBranch : defaultBranch?.name}
onSelectBranch={this.onSelectBranch}
switchViewLink={this.evaluateSwitchViewLink()}
/>
<div className="panel">
<Route
path={`${url}/:page?`}
component={() => (
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} />
)}
/>
</div>
</>
);
}
}
export default compose(withRouter, withTranslation("repos"))(ChangesetsRoot);
export default ChangesetRoot;

View File

@@ -21,143 +21,56 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { NamespaceStrategies, Repository, RepositoryCreation, RepositoryType } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Page } from "@scm-manager/ui-components";
import {
fetchRepositoryTypesIfNeeded,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending
} from "../modules/repositoryTypes";
import RepositoryForm from "../components/form";
import RepositoryFormSwitcher from "../components/form/RepositoryFormSwitcher";
import { createRepo, createRepoReset, getCreateRepoFailure, isCreateRepoPending } from "../modules/repos";
import { getRepositoriesLink } from "../../modules/indexResource";
import {
fetchNamespaceStrategiesIfNeeded,
getFetchNamespaceStrategiesFailure,
getNamespaceStrategies,
isFetchNamespaceStrategiesPending
} from "../../admin/modules/namespaceStrategies";
import { RouteComponentProps, withRouter } from "react-router-dom";
import { compose } from "redux";
type Props = WithTranslation &
RouteComponentProps & {
repositoryTypes: RepositoryType[];
namespaceStrategies: NamespaceStrategies;
pageLoading: boolean;
createLoading: boolean;
error: Error;
repoLink: string;
indexResources: any;
// dispatch functions
fetchNamespaceStrategiesIfNeeded: () => void;
fetchRepositoryTypesIfNeeded: () => void;
createRepo: (
link: string,
repository: RepositoryCreation,
initRepository: boolean,
callback: (repo: Repository) => void
) => void;
resetForm: () => void;
// context props
history: History;
};
class CreateRepository extends React.Component<Props> {
componentDidMount() {
this.props.resetForm();
this.props.fetchRepositoryTypesIfNeeded();
this.props.fetchNamespaceStrategiesIfNeeded();
}
repoCreated = (repo: Repository) => {
this.props.history.push("/repo/" + repo.namespace + "/" + repo.name);
};
render() {
const {
pageLoading,
createLoading,
repositoryTypes,
namespaceStrategies,
createRepo,
error,
indexResources,
repoLink,
t
} = this.props;
return (
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
afterTitle={<RepositoryFormSwitcher creationMode={"CREATE"} />}
loading={pageLoading}
error={error}
showContentOnError={true}
>
<RepositoryForm
repositoryTypes={repositoryTypes}
loading={createLoading}
namespaceStrategy={namespaceStrategies.current}
createRepository={(repo, initRepository) => {
createRepo(repoLink, repo, initRepository, (repo: Repository) => this.repoCreated(repo));
}}
indexResources={indexResources}
/>
</Page>
);
}
}
const mapStateToProps = (state: any) => {
const repositoryTypes = getRepositoryTypes(state);
const namespaceStrategies = getNamespaceStrategies(state);
const pageLoading = isFetchRepositoryTypesPending(state) || isFetchNamespaceStrategiesPending(state);
const createLoading = isCreateRepoPending(state);
const error =
getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state) || getFetchNamespaceStrategiesFailure(state);
const repoLink = getRepositoriesLink(state);
const indexResources = state?.indexResources;
import { Redirect } from "react-router-dom";
import { useCreateRepository, useIndex, useNamespaceStrategies, useRepositoryTypes } from "@scm-manager/ui-api";
const useCreateRepositoryData = () => {
const { isLoading: isLoadingNS, error: errorNS, data: namespaceStrategies } = useNamespaceStrategies();
const { isLoading: isLoadingRT, error: errorRT, data: repositoryTypes } = useRepositoryTypes();
const { isLoading: isLoadingIdx, error: errorIdx, data: index } = useIndex();
return {
repositoryTypes,
isPageLoading: isLoadingNS || isLoadingRT || isLoadingIdx,
pageLoadingError: errorNS || errorRT || errorIdx || undefined,
namespaceStrategies,
pageLoading,
createLoading,
error,
repoLink,
indexResources
repositoryTypes,
index
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
},
createRepo: (link: string, repository: RepositoryCreation, initRepository: boolean, callback: () => void) => {
dispatch(createRepo(link, repository, initRepository, callback));
},
resetForm: () => {
dispatch(createRepoReset());
}
};
const CreateRepository: FC = () => {
const { isPageLoading, pageLoadingError, namespaceStrategies, repositoryTypes, index } = useCreateRepositoryData();
const { isLoading, error, repository, create } = useCreateRepository();
const [t] = useTranslation("repos");
if (repository) {
return <Redirect to={`/repo/${repository.namespace}/${repository.name}`} />;
}
return (
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
afterTitle={<RepositoryFormSwitcher creationMode={"CREATE"} />}
loading={isPageLoading}
error={pageLoadingError || error || undefined}
showContentOnError={true}
>
{namespaceStrategies && repositoryTypes ? (
<RepositoryForm
repositoryTypes={repositoryTypes._embedded.repositoryTypes}
loading={isLoading}
namespaceStrategy={namespaceStrategies!.current}
createRepository={create}
indexResources={index}
/>
) : null}
</Page>
);
};
export default compose(
withRouter,
withTranslation("repos"),
connect(mapStateToProps, mapDispatchToProps)
)(CreateRepository);
export default CreateRepository;

View File

@@ -21,49 +21,43 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { connect } from "react-redux";
import React, { FC, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos";
import { useDeleteRepository } from "@scm-manager/ui-api";
type Props = {
loading: boolean;
error: Error;
repository: Repository;
confirmDialog?: boolean;
deleteRepo: (p1: Repository, p2: () => void) => void;
};
const DeleteRepo: FC<Props> = ({ confirmDialog = true, repository, deleteRepo, loading, error }: Props) => {
const DeleteRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
const history = useHistory();
const { isLoading, error, remove, isDeleted } = useDeleteRepository({
onSuccess: () => {
history.push("/repos/");
}
});
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const history = useHistory();
const deleted = () => {
history.push("/repos/");
};
useEffect(() => {
if (isDeleted) {
setShowConfirmAlert(false);
}
}, [isDeleted]);
const deleteRepoCallback = () => {
deleteRepo(repository, deleted);
remove(repository);
};
const confirmDelete = () => {
setShowConfirmAlert(true);
};
const isDeletable = () => {
return repository._links.delete;
};
const action = confirmDialog ? confirmDelete : deleteRepoCallback;
if (!isDeletable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
@@ -96,28 +90,10 @@ const DeleteRepo: FC<Props> = ({ confirmDialog = true, repository, deleteRepo, l
{t("deleteRepo.description")}
</p>
}
right={<DeleteButton label={t("deleteRepo.button")} action={action} loading={loading} />}
right={<DeleteButton label={t("deleteRepo.button")} action={action} loading={isLoading} />}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isDeleteRepoPending(state, namespace, name);
const error = getDeleteRepoFailure(state, namespace, name);
return {
loading,
error
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
deleteRepo: (repo: Repository, callback: () => void) => {
dispatch(deleteRepo(repo, callback));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(DeleteRepo);
export default DeleteRepo;

View File

@@ -21,98 +21,48 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom";
import React, { FC } from "react";
import { Redirect, useRouteMatch } from "react-router-dom";
import RepositoryForm from "../components/form";
import { Repository, Links } from "@scm-manager/ui-types";
import { getModifyRepoFailure, isModifyRepoPending, modifyRepo, modifyRepoReset } from "../modules/repos";
import { History } from "history";
import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Subtitle } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { compose } from "redux";
import RepositoryDangerZone from "./RepositoryDangerZone";
import { getLinks } from "../../modules/indexResource";
import { urls } from "@scm-manager/ui-components";
import { TranslationProps, withTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import ExportRepository from "./ExportRepository";
import { useIndexLinks, useUpdateRepository } from "@scm-manager/ui-api";
type Props = TranslationProps &
RouteComponentProps & {
loading: boolean;
error: Error;
indexLinks: Links;
modifyRepo: (p1: Repository, p2: () => void) => void;
modifyRepoReset: (p: Repository) => void;
// context props
repository: Repository;
history: History;
};
class EditRepo extends React.Component<Props> {
componentDidMount() {
const { modifyRepoReset, repository } = this.props;
modifyRepoReset(repository);
}
repoModified = () => {
const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.name}`);
};
render() {
const { loading, error, repository, indexLinks, t } = this.props;
const url = urls.matchedUrl(this.props);
const extensionProps = {
repository,
url
};
return (
<>
<Subtitle subtitle={t("repositoryForm.subtitle")} />
<ErrorNotification error={error} />
<RepositoryForm
repository={this.props.repository}
loading={loading}
modifyRepository={repo => {
this.props.modifyRepo(repo, this.repoModified);
}}
/>
<ExportRepository repository={this.props.repository}/>
<ExtensionPoint name="repo-config.route" props={extensionProps} renderAll={true} />
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
</>
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
const indexLinks = getLinks(state);
return {
loading,
error,
indexLinks
};
type Props = {
repository: Repository;
};
const mapDispatchToProps = (dispatch: any) => {
return {
modifyRepo: (repo: Repository, callback: () => void) => {
dispatch(modifyRepo(repo, callback));
},
modifyRepoReset: (repo: Repository) => {
dispatch(modifyRepoReset(repo));
}
const EditRepo: FC<Props> = ({ repository }) => {
const match = useRouteMatch();
const { isLoading, error, update, isUpdated } = useUpdateRepository();
const indexLinks = useIndexLinks();
const [t] = useTranslation("repos");
if (isUpdated) {
return <Redirect to={`/repo/${repository.namespace}/${repository.name}`} />;
}
const url = urls.matchedUrlFromMatch(match);
const extensionProps = {
repository,
url
};
return (
<>
<Subtitle subtitle={t("repositoryForm.subtitle")} />
<ErrorNotification error={error} />
<RepositoryForm repository={repository} loading={isLoading} modifyRepository={update} />
<ExportRepository repository={repository} />
<ExtensionPoint name="repo-config.route" props={extensionProps} renderAll={true} />
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
</>
);
};
export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(withTranslation("repos")(EditRepo));
export default EditRepo;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import React, { FC, useState } from "react";
import { Link, RepositoryType } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
@@ -31,24 +31,16 @@ import ImportTypeSelect from "../components/ImportTypeSelect";
import ImportRepositoryFromUrl from "../components/ImportRepositoryFromUrl";
import { Loading, Notification, Page, useNavigationLock } from "@scm-manager/ui-components";
import RepositoryFormSwitcher from "../components/form/RepositoryFormSwitcher";
import {
fetchRepositoryTypesIfNeeded,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending
} from "../modules/repositoryTypes";
import { connect } from "react-redux";
import { fetchNamespaceStrategiesIfNeeded } from "../../admin/modules/namespaceStrategies";
import ImportRepositoryFromBundle from "../components/ImportRepositoryFromBundle";
import ImportFullRepository from "../components/ImportFullRepository";
import { useRepositoryTypes } from "@scm-manager/ui-api";
import { Prompt } from "react-router-dom";
type Props = {
repositoryTypes: RepositoryType[];
pageLoading: boolean;
error?: Error;
fetchRepositoryTypesIfNeeded: () => void;
fetchNamespaceStrategiesIfNeeded: () => void;
};
const ImportPendingLoading = ({ importPending }: { importPending: boolean }) => {
@@ -65,23 +57,13 @@ const ImportPendingLoading = ({ importPending }: { importPending: boolean }) =>
);
};
const ImportRepository: FC<Props> = ({
repositoryTypes,
pageLoading,
error,
fetchRepositoryTypesIfNeeded,
fetchNamespaceStrategiesIfNeeded
}) => {
const ImportRepository: FC = () => {
const { data: repositoryTypes, error, isLoading } = useRepositoryTypes();
const [importPending, setImportPending] = useState(false);
const [repositoryType, setRepositoryType] = useState<RepositoryType | undefined>();
const [importType, setImportType] = useState("");
const [t] = useTranslation("repos");
useEffect(() => {
fetchRepositoryTypesIfNeeded();
fetchNamespaceStrategiesIfNeeded();
}, [repositoryTypes]);
useNavigationLock(importPending);
const changeRepositoryType = (repositoryType: RepositoryType) => {
@@ -130,14 +112,14 @@ const ImportRepository: FC<Props> = ({
title={t("create.title")}
subtitle={t("import.subtitle")}
afterTitle={<RepositoryFormSwitcher creationMode={"IMPORT"} />}
loading={pageLoading}
error={error}
loading={isLoading}
error={error || undefined}
showContentOnError={true}
>
<Prompt when={importPending} message={t("import.navigationWarning")} />
<ImportPendingLoading importPending={importPending} />
<ImportRepositoryTypeSelect
repositoryTypes={repositoryTypes}
repositoryTypes={repositoryTypes?._embedded.repositoryTypes || []}
repositoryType={repositoryType}
setRepositoryType={changeRepositoryType}
disabled={importPending}
@@ -159,27 +141,4 @@ const ImportRepository: FC<Props> = ({
);
};
const mapStateToProps = (state: any) => {
const repositoryTypes = getRepositoryTypes(state);
const pageLoading = isFetchRepositoryTypesPending(state);
const error = getFetchRepositoryTypesFailure(state);
return {
repositoryTypes,
pageLoading,
error
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ImportRepository);
export default ImportRepository;

View File

@@ -21,11 +21,9 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { Link, NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
import React, { FC, useState } from "react";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
CreateButton,
LinkPaginator,
@@ -35,178 +33,126 @@ import {
PageActions,
urls
} from "@scm-manager/ui-components";
import { getNamespacesLink, getRepositoriesLink } from "../../modules/indexResource";
import {
fetchNamespaces,
fetchReposByPage,
getFetchReposFailure,
getNamespaceCollection,
getRepositoryCollection,
isAbleToCreateRepos,
isFetchNamespacesPending,
isFetchReposPending
} from "../modules/repos";
import RepositoryList from "../components/list";
import { useNamespaces, useRepositories } from "@scm-manager/ui-api";
import { NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
type Props = WithTranslation &
RouteComponentProps & {
loading: boolean;
error: Error;
showCreateButton: boolean;
collection: RepositoryCollection;
namespaces: NamespaceCollection;
page: number;
namespace: string;
reposLink: string;
namespacesLink: string;
const useUrlParams = () => {
const params = useParams();
return urls.getNamespaceAndPageFromMatch({ params });
};
// dispatched functions
fetchReposByPage: (link: string, page: number, filter?: string) => void;
fetchNamespaces: (link: string, callback?: () => void) => void;
const useOverviewData = () => {
const { namespace, page } = useUrlParams();
const { isLoading: isLoadingNamespaces, error: errorNamespaces, data: namespaces } = useNamespaces();
const location = useLocation();
const search = urls.getQueryStringFromLocation(location);
const request = {
namespace: namespaces?._embedded.namespaces.find(n => n.namespace === namespace),
// ui starts counting by 1,
// but backend starts counting by 0
page: page - 1,
search,
// if a namespaces is selected we have to wait
// until the list of namespaces are loaded from the server
disabled: !!namespace && !namespaces
};
const { isLoading: isLoadingRepositories, error: errorRepositories, data: repositories } = useRepositories(request);
class Overview extends React.Component<Props> {
componentDidMount() {
const { fetchNamespaces, namespacesLink } = this.props;
fetchNamespaces(namespacesLink, () => this.fetchRepos());
return {
isLoading: isLoadingNamespaces || isLoadingRepositories,
error: errorNamespaces || errorRepositories || undefined,
namespaces,
namespace,
repositories,
search,
page
};
};
type RepositoriesProps = {
namespaces?: NamespaceCollection;
repositories?: RepositoryCollection;
search: string;
page: number;
};
const Repositories: FC<RepositoriesProps> = ({ namespaces, repositories, search, page }) => {
const [t] = useTranslation("repos");
if (namespaces && repositories) {
if (repositories._embedded && repositories._embedded.repositories.length > 0) {
return (
<>
<RepositoryList repositories={repositories._embedded.repositories} namespaces={namespaces} />
<LinkPaginator collection={repositories} page={page} filter={search} />
</>
);
} else {
return <Notification type="info">{t("overview.noRepositories")}</Notification>;
}
} else {
return null;
}
};
const Overview: FC = () => {
const { isLoading, error, namespace, namespaces, repositories, search, page } = useOverviewData();
const history = useHistory();
const [t] = useTranslation("repos");
// we keep the create permission in the state,
// because it does not change during searching or paging
// and we can avoid bouncing of search bar elements
const [showCreateButton, setShowCreateButton] = useState(false);
if (!showCreateButton && !!repositories?._links.create) {
setShowCreateButton(true);
}
componentDidUpdate = (prevProps: Props) => {
const { loading, collection, namespace, namespaces, page, location } = this.props;
if (namespaces !== prevProps.namespaces && namespace) {
this.fetchRepos();
} else if (collection && (page || namespace) && !loading) {
const statePage: number = collection.page + 1;
if (page !== statePage || prevProps.location.search !== location.search || prevProps.namespace !== namespace) {
this.fetchRepos();
}
}
};
// We need to keep track if we have already load the site,
// because we only know if we can create repositories and
// we need this information to show the create button.
// In order to avoid bouncing of the ui elements we want to
// wait until we have all information before we show the top
// action bar.
const [showActions, setShowActions] = useState(false);
if (!showActions && repositories) {
setShowActions(true);
}
fetchRepos = () => {
const { page, location, fetchReposByPage } = this.props;
const link = this.getReposLink();
if (link) {
fetchReposByPage(link, page, urls.getQueryStringFromLocation(location));
}
};
getReposLink = () => {
const { namespace, namespaces, reposLink } = this.props;
if (namespace) {
return (namespaces?._embedded.namespaces.find(n => n.namespace === namespace)?._links?.repositories as Link)
?.href;
const allNamespacesPlaceholder = t("overview.allNamespaces");
let namespacesToRender: string[] = [];
if (namespaces) {
namespacesToRender = [allNamespacesPlaceholder, ...namespaces._embedded.namespaces.map(n => n.namespace).sort()];
}
const namespaceSelected = (newNamespace: string) => {
if (newNamespace === allNamespacesPlaceholder) {
history.push("/repos/");
} else {
return reposLink;
history.push(`/repos/${newNamespace}/`);
}
};
getNamespaceFilterPlaceholder = () => {
return this.props.t("overview.allNamespaces");
};
namespaceSelected = (newNamespace: string) => {
if (newNamespace === this.getNamespaceFilterPlaceholder()) {
this.props.history.push("/repos/");
} else {
this.props.history.push(`/repos/${newNamespace}/`);
}
};
render() {
const { error, loading, showCreateButton, namespace, namespaces, t } = this.props;
const namespaceFilterPlaceholder = this.getNamespaceFilterPlaceholder();
const namespacesToRender = namespaces
? [namespaceFilterPlaceholder, ...namespaces._embedded.namespaces.map(n => n.namespace).sort()]
: [];
return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
{this.renderOverview()}
<PageActions>
return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={isLoading} error={error}>
<Repositories namespaces={namespaces} repositories={repositories} search={search} page={page} />
{showCreateButton ? <CreateButton label={t("overview.createButton")} link="/repos/create" /> : null}
<PageActions>
{showActions ? (
<OverviewPageActions
showCreateButton={showCreateButton}
currentGroup={namespace}
currentGroup={namespace || ""}
groups={namespacesToRender}
groupSelected={this.namespaceSelected}
groupSelected={namespaceSelected}
link={namespace ? `repos/${namespace}` : "repos"}
label={t("overview.createButton")}
testId="repository-overview"
searchPlaceholder={t("overview.searchRepository")}
/>
</PageActions>
</Page>
);
}
renderRepositoryList() {
const { collection, page, location, namespaces, t } = this.props;
if (collection._embedded && collection._embedded.repositories.length > 0) {
return (
<>
<RepositoryList repositories={collection._embedded.repositories} namespaces={namespaces} />
<LinkPaginator collection={collection} page={page} filter={urls.getQueryStringFromLocation(location)} />
</>
);
}
return <Notification type="info">{t("overview.noRepositories")}</Notification>;
}
renderOverview() {
const { collection } = this.props;
if (collection) {
return (
<>
{this.renderRepositoryList()}
{this.renderCreateButton()}
</>
);
}
return null;
}
renderCreateButton() {
const { showCreateButton, t } = this.props;
if (showCreateButton) {
return <CreateButton label={t("overview.createButton")} link="/repos/create" />;
}
return null;
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { match } = ownProps;
const collection = getRepositoryCollection(state);
const namespaces = getNamespaceCollection(state);
const loading = isFetchReposPending(state) || isFetchNamespacesPending(state);
const error = getFetchReposFailure(state);
const { namespace, page } = urls.getNamespaceAndPageFromMatch(match);
const showCreateButton = isAbleToCreateRepos(state);
const reposLink = getRepositoriesLink(state);
const namespacesLink = getNamespacesLink(state);
return {
collection,
namespaces,
loading,
error,
page,
namespace,
showCreateButton,
reposLink,
namespacesLink
};
) : null}
</PageActions>
</Page>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchReposByPage: (link: string, page: number, filter?: string) => {
dispatch(fetchReposByPage(link, page, filter));
},
fetchNamespaces: (link: string, callback?: () => void) => {
dispatch(fetchNamespaces(link, callback));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("repos")(withRouter(Overview)));
export default Overview;

View File

@@ -23,8 +23,7 @@
*/
import React, { FC, useEffect, useState } from "react";
import { Link, Links, Repository } from "@scm-manager/ui-types";
import { CONTENT_TYPE, CUSTOM_NAMESPACE_STRATEGY } from "../modules/repos";
import { Link, Links, Repository, CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { apiClient } from "@scm-manager/ui-components";
@@ -32,6 +31,8 @@ import { useHistory } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import * as validator from "../components/form/repositoryValidation";
export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
type Props = {
repository: Repository;
indexLinks: Links;

View File

@@ -41,6 +41,7 @@ export const DangerZoneContainer = styled.div`
padding: 1.5rem 1rem;
border: 1px solid #ff6a88;
border-radius: 5px;
> .level {
flex-flow: wrap;
@@ -52,6 +53,7 @@ export const DangerZoneContainer = styled.div`
margin-top: 0.75rem;
}
}
> *:not(:last-child) {
padding-bottom: 1.5rem;
border-bottom: solid 2px whitesmoke;
@@ -66,15 +68,12 @@ const RepositoryDangerZone: FC<Props> = ({ repository, indexLinks }) => {
dangerZone.push(<RenameRepository repository={repository} indexLinks={indexLinks} />);
}
if (repository?._links?.delete) {
// @ts-ignore
dangerZone.push(<DeleteRepo repository={repository} />);
}
if (repository?._links?.archive) {
// @ts-ignore
dangerZone.push(<ArchiveRepo repository={repository} />);
}
if (repository?._links?.unarchive) {
// @ts-ignore
dangerZone.push(<UnarchiveRepo repository={repository} />);
}

View File

@@ -22,11 +22,10 @@
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { Link, Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { Redirect, Route, Link as RouteLink, Switch, useRouteMatch, match } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { Changeset, Repository } from "@scm-manager/ui-types";
import { Changeset, Link } from "@scm-manager/ui-types";
import {
CustomQueryFlexWrappedColumns,
ErrorPage,
@@ -43,7 +42,6 @@ import {
Tooltip,
urls
} from "@scm-manager/ui-components";
import { fetchRepoByName, getFetchRepoFailure, getRepository, isFetchRepoPending } from "../modules/repos";
import RepositoryDetails from "../components/RepositoryDetails";
import EditRepo from "./EditRepo";
import BranchesOverview from "../branches/containers/BranchesOverview";
@@ -53,27 +51,13 @@ import EditRepoNavLink from "../components/EditRepoNavLink";
import BranchRoot from "../branches/containers/BranchRoot";
import PermissionsNavLink from "../components/PermissionsNavLink";
import RepositoryNavLink from "../components/RepositoryNavLink";
import { getLinks, getRepositoriesLink } from "../../modules/indexResource";
import CodeOverview from "../codeSection/containers/CodeOverview";
import ChangesetView from "./ChangesetView";
import SourceExtensions from "../sources/containers/SourceExtensions";
import TagsOverview from "../tags/container/TagsOverview";
import TagRoot from "../tags/container/TagRoot";
import styled from "styled-components";
type Props = RouteComponentProps &
WithTranslation & {
namespace: string;
name: string;
repository: Repository;
loading: boolean;
error: Error;
repoLink: string;
indexLinks: object;
// dispatch functions
fetchRepoByName: (link: string, namespace: string, name: string) => void;
};
import { useIndexLinks, useRepository } from "@scm-manager/ui-api";
const RepositoryTag = styled.span`
margin-left: 0.2rem;
@@ -84,39 +68,143 @@ const RepositoryTag = styled.span`
font-weight: bold;
`;
class RepositoryRoot extends React.Component<Props> {
componentDidMount() {
const { fetchRepoByName, namespace, name, repoLink } = this.props;
fetchRepoByName(repoLink, namespace, name);
type UrlParams = {
namespace: string;
name: string;
};
const useRepositoryFromUrl = (match: match<UrlParams>) => {
const { namespace, name } = match.params;
const { data: repository, ...rest } = useRepository(namespace, name);
return {
repository,
...rest
};
};
const RepositoryRoot = () => {
const match = useRouteMatch<UrlParams>();
const { isLoading, error, repository } = useRepositoryFromUrl(match);
const indexLinks = useIndexLinks();
const [t] = useTranslation("repos");
if (error) {
return (
<ErrorPage title={t("repositoryRoot.errorTitle")} subtitle={t("repositoryRoot.errorSubtitle")} error={error} />
);
}
componentDidUpdate(prevProps: Props) {
const { fetchRepoByName, namespace, name, repoLink } = this.props;
if (namespace !== prevProps.namespace || name !== prevProps.name) {
fetchRepoByName(repoLink, namespace, name);
if (!repository || isLoading) {
return <Loading />;
}
const url = urls.matchedUrlFromMatch(match);
// props used for extensions
// most of the props required for compatibility
const props = {
namespace: repository.namespace,
name: repository.name,
repository: repository,
loading: isLoading,
error,
repoLink: (indexLinks.repositories as Link)?.href,
indexLinks,
match
};
const redirectUrlFactory = binder.getExtension("repository.redirect", props);
let redirectedUrl;
if (redirectUrlFactory) {
redirectedUrl = url + redirectUrlFactory(props);
} else {
redirectedUrl = url + "/info";
}
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const baseUrl = `${url}/code/sources`;
const sourceLink = file.newPath && {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: t("diff.jumpToSource")
};
const targetLink = file.oldPath &&
changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: t("diff.jumpToTarget")
};
const links = [];
switch (file.type) {
case "add":
if (sourceLink) {
links.push(sourceLink);
}
break;
case "delete":
if (targetLink) {
links.push(targetLink);
}
break;
default:
if (targetLink && sourceLink) {
links.push(targetLink, sourceLink); // Target link first because its the previous file
} else if (sourceLink) {
links.push(sourceLink);
}
}
return links ? links.map(({ url, label }) => <JumpToFileButton tooltip={label} link={url} />) : null;
};
const repositoryFlags = [];
if (repository.archived) {
repositoryFlags.push(
<Tooltip message={t("archive.tooltip")}>
<RepositoryTag className="is-size-6">{t("repository.archived")}</RepositoryTag>
</Tooltip>
);
}
matchesBranches = (route: any) => {
const url = urls.matchedUrl(this.props);
if (repository.exporting) {
repositoryFlags.push(
<Tooltip message={t("exporting.tooltip")}>
<RepositoryTag className="is-size-6">{t("repository.exporting")}</RepositoryTag>
</Tooltip>
);
}
const titleComponent = (
<>
<RouteLink to={`/repos/${repository.namespace}/`} className={"has-text-dark"}>
{repository.namespace}
</RouteLink>
/{repository.name}
</>
);
const extensionProps = {
repository,
url,
indexLinks
};
const matchesBranches = (route: any) => {
const regex = new RegExp(`${url}/branch/.+/info`);
return route.location.pathname.match(regex);
};
matchesTags = (route: any) => {
const url = urls.matchedUrl(this.props);
const matchesTags = (route: any) => {
const regex = new RegExp(`${url}/tag/.+/info`);
return route.location.pathname.match(regex);
};
matchesCode = (route: any) => {
const url = urls.matchedUrl(this.props);
const matchesCode = (route: any) => {
const regex = new RegExp(`${url}(/code)/.*`);
return route.location.pathname.match(regex);
};
getCodeLinkname = () => {
const { repository } = this.props;
const getCodeLinkname = () => {
if (repository?._links?.sources) {
return "sources";
}
@@ -126,261 +214,140 @@ class RepositoryRoot extends React.Component<Props> {
return "";
};
evaluateDestinationForCodeLink = () => {
const { repository } = this.props;
const url = `${urls.matchedUrl(this.props)}/code`;
const evaluateDestinationForCodeLink = () => {
if (repository?._links?.sources) {
return `${url}/sources/`;
return `${url}/code/sources/`;
}
return `${url}/changesets`;
return `${url}/code/changesets`;
};
render() {
const { loading, error, indexLinks, repository, t } = this.props;
return (
<StateMenuContextProvider>
<Page
title={titleComponent}
documentTitle={`${repository.namespace}/${repository.name}`}
afterTitle={
<>
<ExtensionPoint name={"repository.afterTitle"} props={{ repository }} />
{repositoryFlags.map(flag => flag)}
</>
}
>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>
<Redirect exact from={match.url} to={redirectedUrl} />
if (error) {
return (
<ErrorPage title={t("repositoryRoot.errorTitle")} subtitle={t("repositoryRoot.errorSubtitle")} error={error} />
);
}
{/* redirect pre 2.0.0-rc2 links */}
<Redirect from={`${url}/changeset/:id`} to={`${url}/code/changeset/:id`} />
<Redirect exact from={`${url}/sources`} to={`${url}/code/sources`} />
<Redirect from={`${url}/sources/:revision/:path*`} to={`${url}/code/sources/:revision/:path*`} />
<Redirect exact from={`${url}/changesets`} to={`${url}/code/changesets`} />
<Redirect from={`${url}/branch/:branch/changesets`} to={`${url}/code/branch/:branch/changesets/`} />
if (!repository || loading) {
return <Loading />;
}
const url = urls.matchedUrl(this.props);
const extensionProps = {
repository,
url,
indexLinks
};
const redirectUrlFactory = binder.getExtension("repository.redirect", this.props);
let redirectedUrl;
if (redirectUrlFactory) {
redirectedUrl = url + redirectUrlFactory(this.props);
} else {
redirectedUrl = url + "/info";
}
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const baseUrl = `${url}/code/sources`;
const sourceLink = file.newPath && {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: t("diff.jumpToSource")
};
const targetLink = file.oldPath &&
changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: t("diff.jumpToTarget")
};
const links = [];
switch (file.type) {
case "add":
if (sourceLink) {
links.push(sourceLink);
}
break;
case "delete":
if (targetLink) {
links.push(targetLink);
}
break;
default:
if (targetLink && sourceLink) {
links.push(targetLink, sourceLink); // Target link first because its the previous file
} else if (sourceLink) {
links.push(sourceLink);
}
}
return links ? links.map(({ url, label }) => <JumpToFileButton tooltip={label} link={url} />) : null;
};
const repositoryFlags = [];
if (repository.archived) {
repositoryFlags.push(
<Tooltip message={t("archive.tooltip")}>
<RepositoryTag className="is-size-6">{t("repository.archived")}</RepositoryTag>
</Tooltip>
);
}
if (repository.exporting) {
repositoryFlags.push(
<Tooltip message={t("exporting.tooltip")}>
<RepositoryTag className="is-size-6">{t("repository.exporting")}</RepositoryTag>
</Tooltip>
);
}
const titleComponent = (
<>
<Link to={`/repos/${repository.namespace}/`} className={"has-text-dark"}>
{repository.namespace}
</Link>
/{repository.name}
</>
);
return (
<StateMenuContextProvider>
<Page
title={titleComponent}
documentTitle={`${repository.namespace}/${repository.name}`}
afterTitle={
<>
<ExtensionPoint name={"repository.afterTitle"} props={{ repository }} />
{repositoryFlags.map(flag => flag)}
</>
}
>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>
<Redirect exact from={this.props.match.url} to={redirectedUrl} />
{/* redirect pre 2.0.0-rc2 links */}
<Redirect from={`${url}/changeset/:id`} to={`${url}/code/changeset/:id`} />
<Redirect exact from={`${url}/sources`} to={`${url}/code/sources`} />
<Redirect from={`${url}/sources/:revision/:path*`} to={`${url}/code/sources/:revision/:path*`} />
<Redirect exact from={`${url}/changesets`} to={`${url}/code/changesets`} />
<Redirect from={`${url}/branch/:branch/changesets`} to={`${url}/code/branch/:branch/changesets/`} />
<Route path={`${url}/info`} exact component={() => <RepositoryDetails repository={repository} />} />
<Route path={`${url}/settings/general`} component={() => <EditRepo repository={repository} />} />
<Route
path={`${url}/settings/permissions`}
render={() => (
<Permissions namespace={this.props.repository.namespace} repoName={this.props.repository.name} />
)}
/>
<Route
exact
path={`${url}/code/changeset/:id`}
render={() => (
<ChangesetView repository={repository} fileControlFactoryFactory={fileControlFactoryFactory} />
)}
/>
<Route
path={`${url}/code/sourceext/:extension`}
exact={true}
render={() => <SourceExtensions repository={repository} />}
/>
<Route
path={`${url}/code/sourceext/:extension/:revision/:path*`}
render={() => <SourceExtensions repository={repository} baseUrl={`${url}/code/sources`} />}
/>
<Route
path={`${url}/code`}
render={() => <CodeOverview baseUrl={`${url}/code`} repository={repository} />}
/>
<Route
path={`${url}/branch/:branch`}
render={() => <BranchRoot repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route
path={`${url}/branches`}
exact={true}
render={() => <BranchesOverview repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route path={`${url}/branches/create`} render={() => <CreateBranch repository={repository} />} />
<Route
path={`${url}/tag/:tag`}
render={() => <TagRoot repository={repository} baseUrl={`${url}/tag`} />}
/>
<Route
path={`${url}/tags`}
exact={true}
render={() => <TagsOverview repository={repository} baseUrl={`${url}/tag`} />}
/>
<ExtensionPoint name="repository.route" props={extensionProps} renderAll={true} />
</Switch>
</PrimaryContentColumn>
<SecondaryNavigationColumn>
<SecondaryNavigation label={t("repositoryRoot.menu.navigationLabel")}>
<ExtensionPoint name="repository.navigation.topLevel" props={extensionProps} renderAll={true} />
<NavLink
to={`${url}/info`}
icon="fas fa-info-circle"
label={t("repositoryRoot.menu.informationNavLink")}
title={t("repositoryRoot.menu.informationNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="branches"
to={`${url}/branches/`}
icon="fas fa-code-branch"
label={t("repositoryRoot.menu.branchesNavLink")}
activeWhenMatch={this.matchesBranches}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.branchesNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="tags"
to={`${url}/tags/`}
icon="fas fa-tags"
label={t("repositoryRoot.menu.tagsNavLink")}
activeWhenMatch={this.matchesTags}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.tagsNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName={this.getCodeLinkname()}
to={this.evaluateDestinationForCodeLink()}
icon="fas fa-code"
label={t("repositoryRoot.menu.sourcesNavLink")}
activeWhenMatch={this.matchesCode}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.sourcesNavLink")}
/>
<ExtensionPoint name="repository.navigation" props={extensionProps} renderAll={true} />
<SubNavigation
to={`${url}/settings/general`}
label={t("repositoryRoot.menu.settingsNavLink")}
title={t("repositoryRoot.menu.settingsNavLink")}
>
<EditRepoNavLink repository={repository} editUrl={`${url}/settings/general`} />
<PermissionsNavLink permissionUrl={`${url}/settings/permissions`} repository={repository} />
<ExtensionPoint name="repository.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>
</SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns>
</Page>
</StateMenuContextProvider>
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.match.params;
const repository = getRepository(state, namespace, name);
const loading = isFetchRepoPending(state, namespace, name);
const error = getFetchRepoFailure(state, namespace, name);
const repoLink = getRepositoriesLink(state);
const indexLinks = getLinks(state);
return {
namespace,
name,
repository,
loading,
error,
repoLink,
indexLinks
};
<Route path={`${url}/info`} exact component={() => <RepositoryDetails repository={repository} />} />
<Route path={`${url}/settings/general`}>
<EditRepo repository={repository} />
</Route>
<Route path={`${url}/settings/permissions`}>
<Permissions namespaceOrRepository={repository} />
</Route>
<Route
exact
path={`${url}/code/changeset/:id`}
render={() => (
<ChangesetView repository={repository} fileControlFactoryFactory={fileControlFactoryFactory} />
)}
/>
<Route
path={`${url}/code/sourceext/:extension`}
exact={true}
render={() => <SourceExtensions repository={repository} />}
/>
<Route
path={`${url}/code/sourceext/:extension/:revision/:path*`}
render={() => <SourceExtensions repository={repository} baseUrl={`${url}/code/sources`} />}
/>
<Route path={`${url}/code`}>
<CodeOverview baseUrl={`${url}/code`} repository={repository} />
</Route>
<Route
path={`${url}/branch/:branch`}
render={() => <BranchRoot repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route
path={`${url}/branches`}
exact={true}
render={() => <BranchesOverview repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route path={`${url}/branches/create`} render={() => <CreateBranch repository={repository} />} />
<Route
path={`${url}/tag/:tag`}
render={() => <TagRoot repository={repository} baseUrl={`${url}/tag`} />}
/>
<Route
path={`${url}/tags`}
exact={true}
render={() => <TagsOverview repository={repository} baseUrl={`${url}/tag`} />}
/>
<ExtensionPoint name="repository.route" props={extensionProps} renderAll={true} />
</Switch>
</PrimaryContentColumn>
<SecondaryNavigationColumn>
<SecondaryNavigation label={t("repositoryRoot.menu.navigationLabel")}>
<ExtensionPoint name="repository.navigation.topLevel" props={extensionProps} renderAll={true} />
<NavLink
to={`${url}/info`}
icon="fas fa-info-circle"
label={t("repositoryRoot.menu.informationNavLink")}
title={t("repositoryRoot.menu.informationNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="branches"
to={`${url}/branches/`}
icon="fas fa-code-branch"
label={t("repositoryRoot.menu.branchesNavLink")}
activeWhenMatch={matchesBranches}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.branchesNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="tags"
to={`${url}/tags/`}
icon="fas fa-tags"
label={t("repositoryRoot.menu.tagsNavLink")}
activeWhenMatch={matchesTags}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.tagsNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName={getCodeLinkname()}
to={evaluateDestinationForCodeLink()}
icon="fas fa-code"
label={t("repositoryRoot.menu.sourcesNavLink")}
activeWhenMatch={matchesCode}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.sourcesNavLink")}
/>
<ExtensionPoint name="repository.navigation" props={extensionProps} renderAll={true} />
<SubNavigation
to={`${url}/settings/general`}
label={t("repositoryRoot.menu.settingsNavLink")}
title={t("repositoryRoot.menu.settingsNavLink")}
>
<EditRepoNavLink repository={repository} editUrl={`${url}/settings/general`} />
<PermissionsNavLink permissionUrl={`${url}/settings/permissions`} repository={repository} />
<ExtensionPoint name="repository.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>
</SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns>
</Page>
</StateMenuContextProvider>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepoByName(link, namespace, name));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("repos")(RepositoryRoot));
export default RepositoryRoot;

View File

@@ -22,46 +22,31 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { Button, ConfirmAlert, ErrorNotification, Level } from "@scm-manager/ui-components";
import { getModifyRepoFailure, isModifyRepoPending, unarchiveRepo } from "../modules/repos";
import { useUnarchiveRepository } from "@scm-manager/ui-api";
type Props = {
loading: boolean;
error: Error;
repository: Repository;
confirmDialog?: boolean;
unarchiveRepo: (p1: Repository, p2: () => void) => void;
};
const UnarchiveRepo: FC<Props> = ({ confirmDialog = true, repository, unarchiveRepo, loading, error }: Props) => {
const UnarchiveRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
const { isLoading, error, unarchive } = useUnarchiveRepository();
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const unarchived = () => {
window.location.reload();
};
const unarchiveRepoCallback = () => {
unarchiveRepo(repository, unarchived);
unarchive(repository);
};
const confirmUnarchive = () => {
setShowConfirmAlert(true);
};
const isUnarchiveable = () => {
return repository._links.unarchive;
};
const action = confirmDialog ? confirmUnarchive : unarchiveRepoCallback;
if (!isUnarchiveable()) {
return null;
}
const confirmAlert = (
<ConfirmAlert
title={t("unarchiveRepo.confirmAlert.title")}
@@ -70,12 +55,13 @@ const UnarchiveRepo: FC<Props> = ({ confirmDialog = true, repository, unarchiveR
{
className: "is-outlined",
label: t("unarchiveRepo.confirmAlert.submit"),
onClick: () => unarchiveRepoCallback(),
isLoading,
onClick: () => unarchiveRepoCallback()
},
{
label: t("unarchiveRepo.confirmAlert.cancel"),
onClick: () => null,
},
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
@@ -94,29 +80,17 @@ const UnarchiveRepo: FC<Props> = ({ confirmDialog = true, repository, unarchiveR
</p>
}
right={
<Button color="warning" icon="box-open" label={t("unarchiveRepo.button")} action={action} loading={loading} />
<Button
color="warning"
icon="box-open"
label={t("unarchiveRepo.button")}
action={action}
loading={isLoading}
/>
}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
loading,
error,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
unarchiveRepo: (repo: Repository, callback: () => void) => {
dispatch(unarchiveRepo(repo, callback));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(UnarchiveRepo);
export default UnarchiveRepo;

View File

@@ -1,701 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
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,
isFetchChangesetPending,
isFetchChangesetsPending,
selectListAsCollection,
shouldFetchChangeset
} from "./changesets";
const branch = {
name: "specific",
revision: "123",
_links: {
history: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets"
}
}
};
const repository = {
namespace: "foo",
name: "bar",
type: "GIT",
_links: {
self: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar"
},
changesets: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets"
},
branches: {
href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/branches"
}
}
};
const changesets = {};
describe("changesets", () => {
describe("fetching of changesets", () => {
const DEFAULT_BRANCH_URL = "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets";
const SPECIFIC_BRANCH_URL = "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
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, "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch changesets for specific branch", () => {
const itemId = "foo/bar/specific";
fetchMock.getOnce(SPECIFIC_BRANCH_URL, "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching changesets on error", () => {
const itemId = "foo/bar";
fetchMock.getOnce(DEFAULT_BRANCH_URL, 500);
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESETS_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fail fetching changesets for specific branch on error", () => {
const itemId = "foo/bar/specific";
fetchMock.getOnce(SPECIFIC_BRANCH_URL, 500);
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_CHANGESETS_FAILURE);
expect(store.getActions()[1].payload).toBeDefined();
});
});
it("should fetch changesets by page", () => {
fetchMock.getOnce(DEFAULT_BRANCH_URL + "?page=4", "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
undefined,
changesets
},
itemId: "foo/bar"
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, undefined, 5)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch changesets by branch and page", () => {
fetchMock.getOnce(SPECIFIC_BRANCH_URL + "?page=4", "{}");
const expectedActions = [
{
type: FETCH_CHANGESETS_PENDING,
itemId: "foo/bar/specific"
},
{
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId: "foo/bar/specific"
}
];
const store = mockStore({});
return store.dispatch(fetchChangesets(repository, branch, 5)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
describe("changesets reducer", () => {
const responseBody = {
page: 1,
pageTotal: 10,
_links: {},
_embedded: {
changesets: [
{
id: "changeset1",
author: {
mail: "z@phod.com",
name: "zaphod"
}
},
{
id: "changeset2",
description: "foo"
},
{
id: "changeset3",
description: "bar"
}
],
_embedded: {
tags: [],
branches: [],
parents: []
}
}
};
it("should set state to received changesets", () => {
const newState = reducer({}, fetchChangesetsSuccess(repository, undefined, responseBody));
expect(newState).toBeDefined();
expect(newState["foo/bar"].byId["changeset1"].author.mail).toEqual("z@phod.com");
expect(newState["foo/bar"].byId["changeset2"].description).toEqual("foo");
expect(newState["foo/bar"].byId["changeset3"].description).toEqual("bar");
expect(newState["foo/bar"].byBranch[""]).toEqual({
entry: {
page: 1,
pageTotal: 10,
_links: {}
},
entries: ["changeset1", "changeset2", "changeset3"]
});
});
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": {
byId: {
id2: {
id: "id2"
},
id1: {
id: "id1"
}
},
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
};
const newState = reducer(state, fetchChangesetsSuccess(repository, undefined, responseBody));
const fooBar = newState["foo/bar"];
expect(fooBar.byBranch[""].entries).toEqual(["changeset1", "changeset2", "changeset3"]);
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: {
"foo/bar": {
byId: {
id2: {
id: "id2"
},
id1: {
id: "id1"
}
},
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
}
};
const result = getChangesets(state, repository);
expect(result).toEqual([
{
id: "id1"
},
{
id: "id2"
}
]);
});
it("should return always the same changeset array for the given parameters", () => {
const state = {
changesets: {
"foo/bar": {
byId: {
id2: {
id: "id2"
},
id1: {
id: "id1"
}
},
byBranch: {
"": {
entries: ["id1", "id2"]
}
}
}
}
};
const one = getChangesets(state, repository);
const two = getChangesets(state, repository);
expect(one).toBe(two);
});
it("should return true, when fetching changesets is pending", () => {
const state = {
pending: {
[FETCH_CHANGESETS + "/foo/bar"]: true
}
};
expect(isFetchChangesetsPending(state, repository)).toBeTruthy();
});
it("should return false, when fetching changesets is not pending", () => {
expect(isFetchChangesetsPending({}, repository)).toEqual(false);
});
it("should return error if fetching changesets failed", () => {
const state = {
failure: {
[FETCH_CHANGESETS + "/foo/bar"]: error
}
};
expect(getFetchChangesetsFailure(state, repository)).toEqual(error);
});
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);
});
it("should return always the same empty object", () => {
const state = {
changesets: {}
};
const one = selectListAsCollection(state, repository);
const two = selectListAsCollection(state, repository);
expect(one).toBe(two);
});
});
});

View File

@@ -1,350 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
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 { Action, Branch, PagedCollection, Repository } from "@scm-manager/ui-types";
import memoizeOne from "memoize-one";
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}`;
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, branch?: Branch, page?: number) {
const link = createChangesetsLink(repository, branch, page);
return function(dispatch: any) {
dispatch(fetchChangesetsPending(repository, branch));
return apiClient
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchChangesetsSuccess(repository, branch, data));
})
.catch(cause => {
dispatch(fetchChangesetsFailure(repository, branch, cause));
});
};
}
function createChangesetsLink(repository: Repository, branch?: Branch, page?: number) {
let link = repository._links.changesets.href;
if (branch) {
link = branch._links.history.href;
}
if (page) {
link = link + `?page=${page - 1}`;
}
return link;
}
export function fetchChangesetsPending(repository: Repository, branch?: Branch): Action {
const itemId = createItemId(repository, branch);
return {
type: FETCH_CHANGESETS_PENDING,
itemId
};
}
export function fetchChangesetsSuccess(repository: Repository, branch?: Branch, changesets: any): Action {
return {
type: FETCH_CHANGESETS_SUCCESS,
payload: {
repository,
branch,
changesets
},
itemId: createItemId(repository, branch)
};
}
function fetchChangesetsFailure(repository: Repository, branch?: Branch, error: Error): Action {
return {
type: FETCH_CHANGESETS_FAILURE,
payload: {
repository,
error,
branch
},
itemId: createItemId(repository, branch)
};
}
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;
if (branch) {
itemId = itemId + "/" + branch.name;
}
return itemId;
}
// reducer
export default function reducer(
state: any = {},
action: Action = {
type: "UNKNOWN"
}
): object {
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,
[payload.id]: changeset
}
}
};
case FETCH_CHANGESETS_SUCCESS:
const changesets = payload.changesets._embedded.changesets;
const changesetIds = changesets.map(c => c.id);
const key = action.itemId;
if (!key) {
return state;
}
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,
[repoId]: {
byId: {
...oldState.byId,
...byIds
},
byBranch: {
...oldState.byBranch,
[branchName]: {
entries: changesetIds,
entry: {
page: payload.changesets.page,
pageTotal: payload.changesets.pageTotal,
_links: payload.changesets._links
}
}
}
}
};
default:
return state;
}
}
function extractChangesetsByIds(changesets: any) {
const changesetsByIds = {};
for (const changeset of changesets) {
changesetsByIds[changeset.id] = changeset;
}
return changesetsByIds;
}
//selectors
export function getChangesets(state: object, repository: Repository, branch?: Branch) {
const repoKey = createItemId(repository);
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 collectChangesets(stateRoot, changesets);
}
const mapChangesets = (stateRoot, changesets) => {
return changesets.entries.map((id: string) => {
return stateRoot.byId[id];
});
};
const collectChangesets = memoizeOne(mapChangesets);
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, branch?: Branch) {
return isPending(state, FETCH_CHANGESETS, createItemId(repository, branch));
}
export function getFetchChangesetsFailure(state: object, repository: Repository, branch?: Branch) {
return getFailure(state, FETCH_CHANGESETS, createItemId(repository, branch));
}
const EMPTY = {};
const selectList = (state: object, repository: Repository, branch?: Branch) => {
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 EMPTY;
};
const selectListEntry = (state: object, repository: Repository, branch?: Branch): object => {
const list = selectList(state, repository, branch);
if (list.entry) {
return list.entry;
}
return EMPTY;
};
export const selectListAsCollection = (state: object, repository: Repository, branch?: Branch): PagedCollection => {
return selectListEntry(state, repository, branch);
};

View File

@@ -1,853 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
CREATE_REPO,
CREATE_REPO_FAILURE,
CREATE_REPO_PENDING,
CREATE_REPO_SUCCESS,
createRepo,
DELETE_REPO,
DELETE_REPO_FAILURE,
DELETE_REPO_PENDING,
DELETE_REPO_SUCCESS,
deleteRepo,
FETCH_REPO,
FETCH_REPO_FAILURE,
FETCH_REPO_PENDING,
FETCH_REPO_SUCCESS,
FETCH_REPOS,
FETCH_REPOS_FAILURE,
FETCH_REPOS_PENDING,
FETCH_REPOS_SUCCESS,
fetchRepoByLink,
fetchRepoByName,
fetchRepos,
fetchReposByLink,
fetchReposByPage,
fetchReposSuccess,
fetchRepoSuccess,
getCreateRepoFailure,
getDeleteRepoFailure,
getFetchRepoFailure,
getFetchReposFailure,
getModifyRepoFailure,
getPermissionsLink,
getRepository,
getRepositoryCollection,
isAbleToCreateRepos,
isCreateRepoPending,
isDeleteRepoPending,
isFetchRepoPending,
isFetchReposPending,
isModifyRepoPending,
MODIFY_REPO,
MODIFY_REPO_FAILURE,
MODIFY_REPO_PENDING,
MODIFY_REPO_SUCCESS,
modifyRepo
} from "./repos";
import { Link, Repository, RepositoryCollection } from "@scm-manager/ui-types";
const hitchhikerPuzzle42: Repository = {
contact: "fourtytwo@hitchhiker.com",
creationDate: "2018-07-31T08:58:45.961Z",
description: "the answer to life the universe and everything",
namespace: "hitchhiker",
name: "puzzle42",
type: "svn",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
update: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42"
},
permissions: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/permissions/"
},
tags: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/tags/"
},
branches: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/branches/"
},
changesets: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/changesets/"
},
sources: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/puzzle42/sources/"
}
}
};
const hitchhikerRestatend: Repository = {
contact: "restatend@hitchhiker.com",
creationDate: "2018-07-31T08:58:32.803Z",
description: "restaurant at the end of the universe",
namespace: "hitchhiker",
name: "restatend",
type: "git",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
update: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
},
permissions: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend/permissions/"
},
tags: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend/tags/"
},
branches: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend/branches/"
},
changesets: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend/changesets/"
},
sources: {
href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend/sources/"
}
}
};
const slartiFjords: Repository = {
contact: "slartibartfast@hitchhiker.com",
description: "My award-winning fjords from the Norwegian coast",
namespace: "slarti",
name: "fjords",
type: "hg",
creationDate: "2018-07-31T08:59:05.653Z",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
delete: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
update: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords"
},
permissions: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/permissions/"
},
tags: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/"
},
branches: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/"
},
changesets: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/changesets/"
},
sources: {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/"
}
}
};
const repositoryCollection: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
first: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
last: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/api/v2/repositories/"
}
},
_embedded: {
repositories: [hitchhikerPuzzle42, hitchhikerRestatend, slartiFjords]
}
};
const repositoryCollectionWithNames: RepositoryCollection = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
first: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
last: {
href: "http://localhost:8081/api/v2/repositories/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/api/v2/repositories/"
}
},
_embedded: {
repositories: ["hitchhiker/puzzle42", "hitchhiker/restatend", "slarti/fjords"]
}
};
describe("repos fetch", () => {
const URL = "repositories";
const REPOS_URL = "/api/v2/repositories";
const SORT = "sortBy=namespaceAndName";
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch repos", () => {
fetchMock.getOnce(REPOS_URL_WITH_SORT, repositoryCollection);
const expectedActions = [
{
type: FETCH_REPOS_PENDING
},
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchRepos(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch page 42", () => {
const url = REPOS_URL + "?page=42&" + SORT;
fetchMock.getOnce(url, repositoryCollection);
const expectedActions = [
{
type: FETCH_REPOS_PENDING
},
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByPage(URL, 43)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch repos from link", () => {
fetchMock.getOnce(REPOS_URL + "?" + SORT + "&page=42", repositoryCollection);
const expectedActions = [
{
type: FETCH_REPOS_PENDING
},
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByLink("/repositories?sortBy=namespaceAndName&page=42")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should append sortby parameter and successfully fetch repos from link", () => {
fetchMock.getOnce("/api/v2/repositories?one=1&sortBy=namespaceAndName", repositoryCollection);
const expectedActions = [
{
type: FETCH_REPOS_PENDING
},
{
type: FETCH_REPOS_SUCCESS,
payload: repositoryCollection
}
];
const store = mockStore({});
return store.dispatch(fetchReposByLink("/repositories?one=1")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => {
fetchMock.getOnce(REPOS_URL_WITH_SORT, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepos(URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPOS_PENDING);
expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully fetch repo slarti/fjords by name", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: FETCH_REPO_PENDING,
payload: {
namespace: "slarti",
name: "fjords"
},
itemId: "slarti/fjords"
},
{
type: FETCH_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(fetchRepoByName(URL, "slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, if the request for slarti/fjords by name fails", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepoByName(URL, "slarti", "fjords")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
expect(actions[1].payload.namespace).toBe("slarti");
expect(actions[1].payload.name).toBe("fjords");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("slarti/fjords");
});
});
it("should successfully fetch repo slarti/fjords", () => {
fetchMock.getOnce("http://localhost:8081/api/v2/repositories/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: FETCH_REPO_PENDING,
payload: {
namespace: "slarti",
name: "fjords"
},
itemId: "slarti/fjords"
},
{
type: FETCH_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(fetchRepoByLink(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
fetchMock.getOnce("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepoByLink(slartiFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
expect(actions[1].payload.namespace).toBe("slarti");
expect(actions[1].payload.name).toBe("fjords");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("slarti/fjords");
});
});
it("should successfully create repo slarti/fjords", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201,
headers: {
location: "repositories/slarti/fjords"
}
});
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [
{
type: CREATE_REPO_PENDING
},
{
type: CREATE_REPO_SUCCESS
}
];
const store = mockStore({});
return store.dispatch(createRepo(URL, { ...slartiFjords, contextEntries: {} }, false)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully create repo slarti/fjords and call the callback", () => {
fetchMock.postOnce(REPOS_URL, {
status: 201,
headers: {
location: "repositories/slarti/fjords"
}
});
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
let callMe = "not yet";
const callback = (r: any) => {
expect(r).toEqual(slartiFjords);
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(createRepo(URL, { ...slartiFjords, contextEntries: {} }, false, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("should dispatch failure if server returns status code 500", () => {
fetchMock.postOnce(REPOS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createRepo(URL, { ...slartiFjords, contextEntries: {} }, false)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_REPO_PENDING);
expect(actions[1].type).toEqual(CREATE_REPO_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully delete repo slarti/fjords", () => {
fetchMock.delete("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 204
});
const expectedActions = [
{
type: DELETE_REPO_PENDING,
payload: slartiFjords,
itemId: "slarti/fjords"
},
{
type: DELETE_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully delete repo slarti/fjords and call the callback", () => {
fetchMock.delete("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 204
});
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("should disapatch failure on delete, if server returns status code 500", () => {
fetchMock.delete("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 500
});
const store = mockStore({});
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_REPO_PENDING);
expect(actions[1].type).toEqual(DELETE_REPO_FAILURE);
expect(actions[1].payload.repository).toBe(slartiFjords);
expect(actions[1].payload.error).toBeDefined();
});
});
it("should successfully modify slarti/fjords repo", () => {
fetchMock.putOnce((slartiFjords._links.update as Link).href, {
status: 204
});
fetchMock.getOnce("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 500
});
const editedFjords = {
...slartiFjords
};
editedFjords.description = "coast of africa";
const store = mockStore({});
return store.dispatch(modifyRepo(editedFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
});
});
it("should successfully modify slarti/fjords repo and call the callback", () => {
fetchMock.putOnce((slartiFjords._links.update as Link).href, {
status: 204
});
fetchMock.getOnce("http://localhost:8081/api/v2/repositories/slarti/fjords", {
status: 500
});
const editedFjords = {
...slartiFjords
};
editedFjords.description = "coast of africa";
const store = mockStore({});
let called = false;
const callback = () => {
called = true;
};
return store.dispatch(modifyRepo(editedFjords, callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
expect(called).toBe(true);
});
});
it("should fail modifying on HTTP 500", () => {
fetchMock.putOnce((slartiFjords._links.update as Link).href, {
status: 500
});
const editedFjords = {
...slartiFjords
};
editedFjords.description = "coast of africa";
const store = mockStore({});
return store.dispatch(modifyRepo(editedFjords)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("repos reducer", () => {
it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
it("should return the same state, if the action is undefined", () => {
const state = {
x: true
};
expect(reducer(state)).toBe(state);
});
it("should return the same state, if the action is unknown to the reducer", () => {
const state = {
x: true
};
expect(
reducer(state, {
type: "EL_SPECIALE"
})
).toBe(state);
});
it("should store the repositories by it's namespace and name on FETCH_REPOS_SUCCESS", () => {
const newState = reducer({}, fetchReposSuccess(repositoryCollection));
expect(newState.list.page).toBe(0);
expect(newState.list.pageTotal).toBe(1);
expect(newState.list._embedded.repositories).toEqual([
"hitchhiker/puzzle42",
"hitchhiker/restatend",
"slarti/fjords"
]);
expect(newState.byNames["hitchhiker/puzzle42"]).toBe(hitchhikerPuzzle42);
expect(newState.byNames["hitchhiker/restatend"]).toBe(hitchhikerRestatend);
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
});
it("should store the repo at byNames", () => {
const newState = reducer({}, fetchRepoSuccess(slartiFjords));
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
});
});
describe("repos selectors", () => {
const error = new Error("something goes wrong");
it("should return the repositories collection", () => {
const state = {
repos: {
list: repositoryCollectionWithNames,
byNames: {
"hitchhiker/puzzle42": hitchhikerPuzzle42,
"hitchhiker/restatend": hitchhikerRestatend,
"slarti/fjords": slartiFjords
}
}
};
const collection = getRepositoryCollection(state);
expect(collection).toEqual(repositoryCollection);
});
it("should return true, when fetch repos is pending", () => {
const state = {
pending: {
[FETCH_REPOS]: true
}
};
expect(isFetchReposPending(state)).toEqual(true);
});
it("should return false, when fetch repos is not pending", () => {
expect(isFetchReposPending({})).toEqual(false);
});
it("should return error when fetch repos did fail", () => {
const state = {
failure: {
[FETCH_REPOS]: error
}
};
expect(getFetchReposFailure(state)).toEqual(error);
});
it("should return undefined when fetch repos did not fail", () => {
expect(getFetchReposFailure({})).toBe(undefined);
});
it("should return the repository collection", () => {
const state = {
repos: {
byNames: {
"slarti/fjords": slartiFjords
}
}
};
const repository = getRepository(state, "slarti", "fjords");
expect(repository).toEqual(slartiFjords);
});
it("should return permissions link", () => {
const state = {
repos: {
byNames: {
"slarti/fjords": slartiFjords
}
}
};
const link = getPermissionsLink(state, "slarti", "fjords");
expect(link).toEqual("http://localhost:8081/api/v2/repositories/slarti/fjords/permissions/");
});
it("should return true, when fetch repo is pending", () => {
const state = {
pending: {
[FETCH_REPO + "/slarti/fjords"]: true
}
};
expect(isFetchRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when fetch repo is not pending", () => {
expect(isFetchRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error when fetch repo did fail", () => {
const state = {
failure: {
[FETCH_REPO + "/slarti/fjords"]: error
}
};
expect(getFetchRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
it("should return undefined when fetch repo did not fail", () => {
expect(getFetchRepoFailure({}, "slarti", "fjords")).toBe(undefined);
});
// create
it("should return true, when create repo is pending", () => {
const state = {
pending: {
[CREATE_REPO]: true
}
};
expect(isCreateRepoPending(state)).toEqual(true);
});
it("should return false, when create repo is not pending", () => {
expect(isCreateRepoPending({})).toEqual(false);
});
it("should return error when create repo did fail", () => {
const state = {
failure: {
[CREATE_REPO]: error
}
};
expect(getCreateRepoFailure(state)).toEqual(error);
});
it("should return undefined when create repo did not fail", () => {
expect(getCreateRepoFailure({})).toBe(undefined);
});
// modify
it("should return true, when modify repo is pending", () => {
const state = {
pending: {
[MODIFY_REPO + "/slarti/fjords"]: true
}
};
expect(isModifyRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when modify repo is not pending", () => {
expect(isModifyRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error, when modify repo failed", () => {
const state = {
failure: {
[MODIFY_REPO + "/slarti/fjords"]: error
}
};
expect(getModifyRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
it("should return undefined, when modify did not fail", () => {
expect(getModifyRepoFailure({}, "slarti", "fjords")).toBeUndefined();
});
// delete
it("should return true, when delete repo is pending", () => {
const state = {
pending: {
[DELETE_REPO + "/slarti/fjords"]: true
}
};
expect(isDeleteRepoPending(state, "slarti", "fjords")).toEqual(true);
});
it("should return false, when delete repo is not pending", () => {
expect(isDeleteRepoPending({}, "slarti", "fjords")).toEqual(false);
});
it("should return error when delete repo did fail", () => {
const state = {
failure: {
[DELETE_REPO + "/slarti/fjords"]: error
}
};
expect(getDeleteRepoFailure(state, "slarti", "fjords")).toEqual(error);
});
it("should return undefined when delete repo did not fail", () => {
expect(getDeleteRepoFailure({}, "slarti", "fjords")).toBe(undefined);
});
it("should return true if the list contains the create link", () => {
const state = {
repos: {
list: repositoryCollection
}
};
expect(isAbleToCreateRepos(state)).toBe(true);
});
it("should return false, if create link is unavailable", () => {
const state = {
repos: {
list: {
_links: {}
}
}
};
expect(isAbleToCreateRepos(state)).toBe(false);
});
});

View File

@@ -1,665 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { apiClient } from "@scm-manager/ui-components";
import * as types from "../../modules/types";
import {
Action,
Link,
Namespace,
NamespaceCollection,
Repository,
RepositoryCollection,
RepositoryCreation
} from "@scm-manager/ui-types";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
export const FETCH_REPOS = "scm/repos/FETCH_REPOS";
export const FETCH_REPOS_PENDING = `${FETCH_REPOS}_${types.PENDING_SUFFIX}`;
export const FETCH_REPOS_SUCCESS = `${FETCH_REPOS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_REPOS_FAILURE = `${FETCH_REPOS}_${types.FAILURE_SUFFIX}`;
export const FETCH_NAMESPACES = "scm/repos/FETCH_NAMESPACES";
export const FETCH_NAMESPACES_PENDING = `${FETCH_NAMESPACES}_${types.PENDING_SUFFIX}`;
export const FETCH_NAMESPACES_SUCCESS = `${FETCH_NAMESPACES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_NAMESPACES_FAILURE = `${FETCH_NAMESPACES}_${types.FAILURE_SUFFIX}`;
export const FETCH_REPO = "scm/repos/FETCH_REPO";
export const FETCH_REPO_PENDING = `${FETCH_REPO}_${types.PENDING_SUFFIX}`;
export const FETCH_REPO_SUCCESS = `${FETCH_REPO}_${types.SUCCESS_SUFFIX}`;
export const FETCH_REPO_FAILURE = `${FETCH_REPO}_${types.FAILURE_SUFFIX}`;
export const CREATE_REPO = "scm/repos/CREATE_REPO";
export const CREATE_REPO_PENDING = `${CREATE_REPO}_${types.PENDING_SUFFIX}`;
export const CREATE_REPO_SUCCESS = `${CREATE_REPO}_${types.SUCCESS_SUFFIX}`;
export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`;
export const CREATE_REPO_RESET = `${CREATE_REPO}_${types.RESET_SUFFIX}`;
export const MODIFY_REPO = "scm/repos/MODIFY_REPO";
export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`;
export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`;
export const MODIFY_REPO_RESET = `${MODIFY_REPO}_${types.RESET_SUFFIX}`;
export const DELETE_REPO = "scm/repos/DELETE_REPO";
export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`;
export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`;
export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`;
export const FETCH_NAMESPACE = "scm/repos/FETCH_NAMESPACE";
export const FETCH_NAMESPACE_PENDING = `${FETCH_NAMESPACE}_${types.PENDING_SUFFIX}`;
export const FETCH_NAMESPACE_SUCCESS = `${FETCH_NAMESPACE}_${types.SUCCESS_SUFFIX}`;
export const FETCH_NAMESPACE_FAILURE = `${FETCH_NAMESPACE}_${types.FAILURE_SUFFIX}`;
export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
export const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy";
// fetch repos
const SORT_BY = "sortBy=namespaceAndName";
export function fetchRepos(link: string) {
return fetchReposByLink(link);
}
export function fetchReposByPage(link: string, page: number, filter?: string) {
const linkWithPage = `${link}?page=${page - 1}`;
if (filter) {
return fetchReposByLink(`${linkWithPage}&q=${decodeURIComponent(filter)}`);
}
return fetchReposByLink(linkWithPage);
}
function appendSortByLink(url: string) {
if (url.includes(SORT_BY)) {
return url;
}
let urlWithSortBy = url;
if (url.includes("?")) {
urlWithSortBy += "&";
} else {
urlWithSortBy += "?";
}
return urlWithSortBy + SORT_BY;
}
export function fetchReposByLink(link: string) {
const url = appendSortByLink(link);
return function(dispatch: any) {
dispatch(fetchReposPending());
return apiClient
.get(url)
.then(response => response.json())
.then(repositories => {
dispatch(fetchReposSuccess(repositories));
})
.catch(err => {
dispatch(fetchReposFailure(err));
});
};
}
export function fetchReposPending(): Action {
return {
type: FETCH_REPOS_PENDING
};
}
export function fetchReposSuccess(repositories: RepositoryCollection): Action {
return {
type: FETCH_REPOS_SUCCESS,
payload: repositories
};
}
export function fetchReposFailure(err: Error): Action {
return {
type: FETCH_REPOS_FAILURE,
payload: err
};
}
// fetch namespaces
export function fetchNamespaces(link: string, callback?: () => void) {
return function(dispatch: any) {
dispatch(fetchNamespacesPending());
return apiClient
.get(link)
.then(response => response.json())
.then(namespaces => {
dispatch(fetchNamespacesSuccess(namespaces));
})
.then(callback)
.catch(err => {
dispatch(fetchNamespacesFailure(err));
});
};
}
export function fetchNamespacesPending(): Action {
return {
type: FETCH_NAMESPACES_PENDING
};
}
export function fetchNamespacesSuccess(namespaces: NamespaceCollection): Action {
return {
type: FETCH_NAMESPACES_SUCCESS,
payload: namespaces
};
}
export function fetchNamespacesFailure(err: Error): Action {
return {
type: FETCH_NAMESPACES_FAILURE,
payload: err
};
}
// fetch repo
export function fetchRepoByLink(repo: Repository) {
return fetchRepo((repo._links.self as Link).href, repo.namespace, repo.name);
}
export function fetchRepoByName(link: string, namespace: string, name: string) {
const repoUrl = link.endsWith("/") ? link : link + "/";
return fetchRepo(`${repoUrl}${namespace}/${name}`, namespace, name);
}
function fetchRepo(link: string, namespace: string, name: string) {
return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name));
return apiClient
.get(link)
.then(response => response.json())
.then(repository => {
dispatch(fetchRepoSuccess(repository));
})
.catch(err => {
dispatch(fetchRepoFailure(namespace, name, err));
});
};
}
export function fetchRepoPending(namespace: string, name: string): Action {
return {
type: FETCH_REPO_PENDING,
payload: {
namespace,
name
},
itemId: namespace + "/" + name
};
}
export function fetchRepoSuccess(repository: Repository): Action {
return {
type: FETCH_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function fetchRepoFailure(namespace: string, name: string, error: Error): Action {
return {
type: FETCH_REPO_FAILURE,
payload: {
namespace,
name,
error
},
itemId: namespace + "/" + name
};
}
// create repo
export function createRepo(
link: string,
repository: RepositoryCreation,
initRepository: boolean,
callback?: (repo: Repository) => void
) {
return function(dispatch: any) {
dispatch(createRepoPending());
const repoLink = initRepository ? link + "?initialize=true" : link;
return apiClient
.post(repoLink, repository, CONTENT_TYPE)
.then(response => {
const location = response.headers.get("Location");
dispatch(createRepoSuccess());
// @ts-ignore Location is always set if the repository creation was successful
return apiClient.get(location);
})
.then(response => response.json())
.then(response => {
if (callback) {
callback(response);
}
})
.catch(err => {
dispatch(createRepoFailure(err));
});
};
}
export function createRepoPending(): Action {
return {
type: CREATE_REPO_PENDING
};
}
export function createRepoSuccess(): Action {
return {
type: CREATE_REPO_SUCCESS
};
}
export function createRepoFailure(err: Error): Action {
return {
type: CREATE_REPO_FAILURE,
payload: err
};
}
export function createRepoReset(): Action {
return {
type: CREATE_REPO_RESET
};
}
// modify
export function modifyRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.put((repository._links.update as Link).href, repository, CONTENT_TYPE)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.then(() => {
dispatch(fetchRepoByLink(repository));
})
.catch(error => {
dispatch(modifyRepoFailure(repository, error));
});
};
}
export function modifyRepoPending(repository: Repository): Action {
return {
type: MODIFY_REPO_PENDING,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function modifyRepoSuccess(repository: Repository): Action {
return {
type: MODIFY_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function modifyRepoFailure(repository: Repository, error: Error): Action {
return {
type: MODIFY_REPO_FAILURE,
payload: {
error,
repository
},
itemId: createIdentifier(repository)
};
}
export function modifyRepoReset(repository: Repository): Action {
return {
type: MODIFY_REPO_RESET,
payload: {
repository
},
itemId: createIdentifier(repository)
};
}
// delete
export function deleteRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(deleteRepoPending(repository));
return apiClient
.delete((repository._links.delete as Link).href)
.then(() => {
dispatch(deleteRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(deleteRepoFailure(repository, err));
});
};
}
export function deleteRepoPending(repository: Repository): Action {
return {
type: DELETE_REPO_PENDING,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function deleteRepoSuccess(repository: Repository): Action {
return {
type: DELETE_REPO_SUCCESS,
payload: repository,
itemId: createIdentifier(repository)
};
}
export function deleteRepoFailure(repository: Repository, error: Error): Action {
return {
type: DELETE_REPO_FAILURE,
payload: {
error,
repository
},
itemId: createIdentifier(repository)
};
}
// archive
export function archiveRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.post((repository._links.archive as Link).href)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyRepoFailure(repository, err));
});
};
}
export function unarchiveRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.post((repository._links.unarchive as Link).href)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyRepoFailure(repository, err));
});
};
}
export function fetchNamespace(link: string, namespaceName: string) {
return function(dispatch: any) {
dispatch(fetchNamespacePending(namespaceName));
return apiClient
.get(link)
.then(response => response.json())
.then(namespace => {
dispatch(fetchNamespaceSuccess(namespace));
})
.catch(err => {
dispatch(fetchNamespaceFailure(namespaceName, err));
});
};
}
export function fetchNamespacePending(namespaceName: string): Action {
return {
type: FETCH_NAMESPACE_PENDING,
payload: {
namespaceName
},
itemId: namespaceName
};
}
export function fetchNamespaceSuccess(namespace: Namespace): Action {
return {
type: FETCH_NAMESPACE_SUCCESS,
payload: namespace,
itemId: namespace.namespace
};
}
export function fetchNamespaceFailure(namespaceName: string, error: Error): Action {
return {
type: FETCH_NAMESPACE_FAILURE,
payload: {
namespaceName,
error
},
itemId: namespaceName
};
}
// reducer
function createIdentifier(repository: Repository) {
return repository.namespace + "/" + repository.name;
}
function normalizeByNamespaceAndName(state: object, repositoryCollection: RepositoryCollection) {
const names = [];
const byNames = {};
for (const repository of repositoryCollection._embedded.repositories) {
const identifier = createIdentifier(repository);
names.push(identifier);
byNames[identifier] = repository;
}
return {
...state,
list: {
...repositoryCollection,
_embedded: {
repositories: names
}
},
byNames: byNames
};
}
const reducerByNames = (state: object, repository: Repository) => {
const identifier = createIdentifier(repository);
return {
...state,
byNames: {
...state.byNames,
[identifier]: repository
}
};
};
const reducerForNamespace = (state: object, namespace: Namespace) => {
const identifier = namespace.namespace;
return {
...state,
namespacesByNames: {
...state.namespacesByNames,
[identifier]: namespace
}
};
};
const reducerForNamespaces = (state: object, namespaces: NamespaceCollection) => {
return {
...state,
namespaces: namespaces
};
};
export default function reducer(
state: object = {},
action: Action = {
type: "UNKNOWN"
}
): object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_REPOS_SUCCESS:
return normalizeByNamespaceAndName(state, action.payload);
case FETCH_NAMESPACES_SUCCESS:
return reducerForNamespaces(state, action.payload);
case FETCH_REPO_SUCCESS:
return reducerByNames(state, action.payload);
case FETCH_NAMESPACE_SUCCESS:
return reducerForNamespace(state, action.payload);
default:
return state;
}
}
// selectors
export function getRepositoryCollection(state: object) {
if (state.repos && state.repos.list && state.repos.byNames) {
const repositories = [];
for (const repositoryName of state.repos.list._embedded.repositories) {
repositories.push(state.repos.byNames[repositoryName]);
}
return {
...state.repos.list,
_embedded: {
repositories
}
};
}
}
export function getNamespaceCollection(state: object) {
return state.repos.namespaces;
}
export function isFetchReposPending(state: object) {
return isPending(state, FETCH_REPOS);
}
export function getFetchReposFailure(state: object) {
return getFailure(state, FETCH_REPOS);
}
export function getRepository(state: object, namespace: string, name: string) {
if (state.repos && state.repos.byNames) {
return state.repos.byNames[namespace + "/" + name];
}
}
export function isFetchNamespacesPending(state: object) {
return isPending(state, FETCH_NAMESPACES);
}
export function getFetchNamespacesFailure(state: object) {
return getFailure(state, FETCH_NAMESPACES);
}
export function isFetchNamespacePending(state: object) {
return isPending(state, FETCH_NAMESPACE);
}
export function getFetchNamespaceFailure(state: object) {
return getFailure(state, FETCH_NAMESPACE);
}
export function fetchNamespaceByName(link: string, namespaceName: string) {
const namespaceUrl = link.endsWith("/") ? link : link + "/";
return fetchNamespace(`${namespaceUrl}${namespaceName}`, namespaceName);
}
export function isFetchRepoPending(state: object, namespace: string, name: string) {
return isPending(state, FETCH_REPO, namespace + "/" + name);
}
export function getFetchRepoFailure(state: object, namespace: string, name: string) {
return getFailure(state, FETCH_REPO, namespace + "/" + name);
}
export function getNamespace(state: object, namespaceName: string) {
if (state.repos && state.repos.namespacesByNames) {
return state.repos.namespacesByNames[namespaceName];
}
}
export function isAbleToCreateRepos(state: object) {
return !!(state.repos && state.repos.list && state.repos.list._links && state.repos.list._links.create);
}
export function isCreateRepoPending(state: object) {
return isPending(state, CREATE_REPO);
}
export function getCreateRepoFailure(state: object) {
return getFailure(state, CREATE_REPO);
}
export function isModifyRepoPending(state: object, namespace: string, name: string) {
return isPending(state, MODIFY_REPO, namespace + "/" + name);
}
export function getModifyRepoFailure(state: object, namespace: string, name: string) {
return getFailure(state, MODIFY_REPO, namespace + "/" + name);
}
export function isDeleteRepoPending(state: object, namespace: string, name: string) {
return isPending(state, DELETE_REPO, namespace + "/" + name);
}
export function getDeleteRepoFailure(state: object, namespace: string, name: string) {
return getFailure(state, DELETE_REPO, namespace + "/" + name);
}
export function getPermissionsLink(state: object, namespaceName: string, repoName?: string) {
if (repoName) {
const repo = getRepository(state, namespaceName, repoName);
return repo?._links ? repo._links.permissions.href : undefined;
} else {
const namespace = getNamespace(state, namespaceName);
return namespace?._links ? namespace?._links?.permissions?.href : undefined;
}
}

View File

@@ -1,221 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import fetchMock from "fetch-mock";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import reducer, {
FETCH_REPOSITORY_TYPES,
FETCH_REPOSITORY_TYPES_FAILURE,
FETCH_REPOSITORY_TYPES_PENDING,
FETCH_REPOSITORY_TYPES_SUCCESS,
fetchRepositoryTypesIfNeeded,
fetchRepositoryTypesSuccess,
getFetchRepositoryTypesFailure,
getRepositoryTypes,
isFetchRepositoryTypesPending,
shouldFetchRepositoryTypes
} from "./repositoryTypes";
const git = {
name: "git",
displayName: "Git",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/git"
}
}
};
const hg = {
name: "hg",
displayName: "Mercurial",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/hg"
}
}
};
const svn = {
name: "svn",
displayName: "Subversion",
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes/svn"
}
}
};
const collection = {
_embedded: {
repositoryTypes: [git, hg, svn]
},
_links: {
self: {
href: "http://localhost:8081/api/v2/repositoryTypes"
}
}
};
describe("repository types caching", () => {
it("should fetch repository types, on empty state", () => {
expect(shouldFetchRepositoryTypes({})).toBe(true);
});
it("should fetch repository types, if the state contains an empty array", () => {
const state = {
repositoryTypes: []
};
expect(shouldFetchRepositoryTypes(state)).toBe(true);
});
it("should not fetch repository types, on pending state", () => {
const state = {
pending: {
[FETCH_REPOSITORY_TYPES]: true
}
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
it("should not fetch repository types, on failure state", () => {
const state = {
failure: {
[FETCH_REPOSITORY_TYPES]: new Error("no...")
}
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
it("should not fetch repository types, if they are already fetched", () => {
const state = {
repositoryTypes: [git, hg, svn]
};
expect(shouldFetchRepositoryTypes(state)).toBe(false);
});
});
describe("repository types fetch", () => {
const URL = "/api/v2/repositoryTypes";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch repository types", () => {
fetchMock.getOnce(URL, collection);
const expectedActions = [
{
type: FETCH_REPOSITORY_TYPES_PENDING
},
{
type: FETCH_REPOSITORY_TYPES_SUCCESS,
payload: collection
}
];
const store = mockStore({});
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPOSITORY_TYPES_FAILURE on server error", () => {
fetchMock.getOnce(URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toBe(FETCH_REPOSITORY_TYPES_PENDING);
expect(actions[1].type).toBe(FETCH_REPOSITORY_TYPES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should dispatch not dispatch any action, if the repository types are already fetched", () => {
const store = mockStore({
repositoryTypes: [git, hg, svn]
});
store.dispatch(fetchRepositoryTypesIfNeeded());
expect(store.getActions().length).toBe(0);
});
});
describe("repository types reducer", () => {
it("should return unmodified state on unknown action", () => {
const state = [];
expect(reducer(state)).toBe(state);
});
it("should store the repository types on FETCH_REPOSITORY_TYPES_SUCCESS", () => {
const newState = reducer([], fetchRepositoryTypesSuccess(collection));
expect(newState).toEqual([git, hg, svn]);
});
});
describe("repository types selectors", () => {
const error = new Error("The end of the universe");
it("should return an emtpy array", () => {
expect(getRepositoryTypes({})).toEqual([]);
});
it("should return the repository types", () => {
const state = {
repositoryTypes: [git, hg, svn]
};
expect(getRepositoryTypes(state)).toEqual([git, hg, svn]);
});
it("should return true, when fetch repository types is pending", () => {
const state = {
pending: {
[FETCH_REPOSITORY_TYPES]: true
}
};
expect(isFetchRepositoryTypesPending(state)).toEqual(true);
});
it("should return false, when fetch repos is not pending", () => {
expect(isFetchRepositoryTypesPending({})).toEqual(false);
});
it("should return error when fetch repository types did fail", () => {
const state = {
failure: {
[FETCH_REPOSITORY_TYPES]: error
}
};
expect(getFetchRepositoryTypesFailure(state)).toEqual(error);
});
it("should return undefined when fetch repos did not fail", () => {
expect(getFetchRepositoryTypesFailure({})).toBe(undefined);
});
});

View File

@@ -1,113 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as types from "../../modules/types";
import { Action, RepositoryType, RepositoryTypeCollection } 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_REPOSITORY_TYPES = "scm/repos/FETCH_REPOSITORY_TYPES";
export const FETCH_REPOSITORY_TYPES_PENDING = `${FETCH_REPOSITORY_TYPES}_${types.PENDING_SUFFIX}`;
export const FETCH_REPOSITORY_TYPES_SUCCESS = `${FETCH_REPOSITORY_TYPES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_REPOSITORY_TYPES_FAILURE = `${FETCH_REPOSITORY_TYPES}_${types.FAILURE_SUFFIX}`;
export function fetchRepositoryTypesIfNeeded() {
return function(dispatch: any, getState: () => object) {
if (shouldFetchRepositoryTypes(getState())) {
return fetchRepositoryTypes(dispatch);
}
};
}
function fetchRepositoryTypes(dispatch: any) {
dispatch(fetchRepositoryTypesPending());
return apiClient
.get("repositoryTypes")
.then(response => response.json())
.then(repositoryTypes => {
dispatch(fetchRepositoryTypesSuccess(repositoryTypes));
})
.catch(error => {
dispatch(fetchRepositoryTypesFailure(error));
});
}
export function shouldFetchRepositoryTypes(state: object) {
if (isFetchRepositoryTypesPending(state) || getFetchRepositoryTypesFailure(state)) {
return false;
}
return !(state.repositoryTypes && state.repositoryTypes.length > 0);
}
export function fetchRepositoryTypesPending(): Action {
return {
type: FETCH_REPOSITORY_TYPES_PENDING
};
}
export function fetchRepositoryTypesSuccess(repositoryTypes: RepositoryTypeCollection): Action {
return {
type: FETCH_REPOSITORY_TYPES_SUCCESS,
payload: repositoryTypes
};
}
export function fetchRepositoryTypesFailure(error: Error): Action {
return {
type: FETCH_REPOSITORY_TYPES_FAILURE,
payload: error
};
}
// reducers
export default function reducer(
state: RepositoryType[] = [],
action: Action = {
type: "UNKNOWN"
}
): RepositoryType[] {
if (action.type === FETCH_REPOSITORY_TYPES_SUCCESS && action.payload) {
return action.payload._embedded["repositoryTypes"];
}
return state;
}
// selectors
export function getRepositoryTypes(state: object) {
if (state.repositoryTypes) {
return state.repositoryTypes;
}
return [];
}
export function isFetchRepositoryTypesPending(state: object) {
return isPending(state, FETCH_REPOSITORY_TYPES);
}
export function getFetchRepositoryTypesFailure(state: object) {
return getFailure(state, FETCH_REPOSITORY_TYPES);
}

View File

@@ -22,13 +22,9 @@
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { fetchNamespaceByName, getNamespace, isFetchNamespacePending } from "../../modules/repos";
import { getNamespacesLink } from "../../../modules/indexResource";
import { Namespace } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom";
import {
CustomQueryFlexWrappedColumns,
ErrorPage,
@@ -44,94 +40,63 @@ import Permissions from "../../permissions/containers/Permissions";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import PermissionsNavLink from "./PermissionsNavLink";
import { urls } from "@scm-manager/ui-components";
import { useNamespace } from "@scm-manager/ui-api";
type Props = RouteComponentProps &
WithTranslation & {
loading: boolean;
namespaceName: string;
namespacesLink: string;
namespace: Namespace;
error: Error;
type Params = {
namespaceName: string;
};
// dispatch functions
fetchNamespace: (link: string, namespace: string) => void;
};
class NamespaceRoot extends React.Component<Props> {
componentDidMount() {
const { namespacesLink, namespaceName, fetchNamespace } = this.props;
fetchNamespace(namespacesLink, namespaceName);
}
render() {
const { loading, error, namespaceName, namespace, t } = this.props;
const url = urls.matchedUrl(this.props);
const extensionProps = {
namespace,
url
};
if (error) {
return (
<ErrorPage title={t("namespaceRoot.errorTitle")} subtitle={t("namespaceRoot.errorSubtitle")} error={error} />
);
}
if (!namespace || loading) {
return <Loading />;
}
const NamespaceRoot: FC = () => {
const match = useRouteMatch<Params>();
const { isLoading, error, data: namespace } = useNamespace(match.params.namespaceName);
const [t] = useTranslation("namespaces");
const url = urls.matchedUrlFromMatch(match);
if (error) {
return (
<StateMenuContextProvider>
<Page title={namespaceName}>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>
<Redirect exact from={`${url}/settings`} to={`${url}/settings/permissions`} />
<Route
path={`${url}/settings/permissions`}
render={() => {
return <Permissions namespace={namespaceName} />;
}}
/>
</Switch>
</PrimaryContentColumn>
<SecondaryNavigationColumn>
<SecondaryNavigation label={t("namespaceRoot.menu.navigationLabel")}>
<ExtensionPoint name="namespace.navigation.topLevel" props={extensionProps} renderAll={true} />
<ExtensionPoint name="namespace.route" props={extensionProps} renderAll={true} />
<SubNavigation
to={`${url}/settings`}
label={t("namespaceRoot.menu.settingsNavLink")}
title={t("namespaceRoot.menu.settingsNavLink")}
>
<PermissionsNavLink permissionUrl={`${url}/settings/permissions`} namespace={namespace} />
<ExtensionPoint name="namespace.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>
</SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns>
</Page>
</StateMenuContextProvider>
<ErrorPage title={t("namespaceRoot.errorTitle")} subtitle={t("namespaceRoot.errorSubtitle")} error={error} />
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespaceName } = ownProps.match.params;
const namespacesLink = getNamespacesLink(state);
const namespace = getNamespace(state, namespaceName);
const loading = isFetchNamespacePending(state);
return { namespaceName, namespacesLink, loading, namespace };
};
if (!namespace || isLoading) {
return <Loading />;
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchNamespace: (link: string, namespaceName: string) => {
dispatch(fetchNamespaceByName(link, namespaceName));
}
const extensionProps = {
namespace,
url
};
return (
<StateMenuContextProvider>
<Page title={namespace.namespace}>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>
<Redirect exact from={`${url}/settings`} to={`${url}/settings/permissions`} />
<Route path={`${url}/settings/permissions`}>
<Permissions namespaceOrRepository={namespace} />
</Route>
</Switch>
</PrimaryContentColumn>
<SecondaryNavigationColumn>
<SecondaryNavigation label={t("namespaceRoot.menu.navigationLabel")}>
<ExtensionPoint name="namespace.navigation.topLevel" props={extensionProps} renderAll={true} />
<ExtensionPoint name="namespace.route" props={extensionProps} renderAll={true} />
<SubNavigation
to={`${url}/settings`}
label={t("namespaceRoot.menu.settingsNavLink")}
title={t("namespaceRoot.menu.settingsNavLink")}
>
<PermissionsNavLink permissionUrl={`${url}/settings/permissions`} namespace={namespace} />
<ExtensionPoint name="namespace.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>
</SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns>
</Page>
</StateMenuContextProvider>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("namespaces")(NamespaceRoot));
export default NamespaceRoot;

View File

@@ -21,47 +21,37 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Permission } from "@scm-manager/ui-types";
import { ConfirmAlert } from "@scm-manager/ui-components";
import { Namespace, Permission, Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeletePermission } from "@scm-manager/ui-api";
type Props = {
permission: Permission;
namespace: string;
repoName: string;
namespaceOrRepository: Namespace | Repository;
confirmDialog?: boolean;
deletePermission: (permission: Permission, namespace: string, repoName: string) => void;
loading: boolean;
};
const DeletePermissionButton: FC<Props> = ({
confirmDialog = true,
permission,
namespace,
deletePermission,
repoName
}) => {
const DeletePermissionButton: FC<Props> = ({ namespaceOrRepository, permission, confirmDialog = true }) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const { isLoading, error, remove, isDeleted } = useDeletePermission(namespaceOrRepository);
const [t] = useTranslation("repos");
useEffect(() => {
if (isDeleted) {
setShowConfirmAlert(false);
}
}, [isDeleted]);
const deletePermissionCallback = () => {
deletePermission(permission, namespace, repoName);
const deletePermission = () => {
remove(permission);
};
const confirmDelete = () => {
setShowConfirmAlert(true);
};
const isDeletable = () => {
return permission._links.delete;
};
const action = confirmDialog ? confirmDelete : deletePermissionCallback;
if (!isDeletable()) {
return null;
}
const action = confirmDialog ? confirmDelete : deletePermission;
if (showConfirmAlert) {
return (
@@ -72,7 +62,8 @@ const DeletePermissionButton: FC<Props> = ({
{
className: "is-outlined",
label: t("permission.delete-permission-button.confirm-alert.submit"),
onClick: () => deletePermissionCallback()
isLoading,
onClick: () => deletePermission()
},
{
label: t("permission.delete-permission-button.confirm-alert.cancel"),
@@ -85,11 +76,14 @@ const DeletePermissionButton: FC<Props> = ({
}
return (
<a className="level-item" onClick={action}>
<span className="icon is-small">
<i className="fas fa-trash" />
</span>
</a>
<>
<ErrorNotification error={error} />
<a className="level-item" onClick={action}>
<span className="icon is-small">
<i className="fas fa-trash" />
</span>
</a>
</>
);
};

View File

@@ -26,29 +26,37 @@ import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { LabelWithHelpIcon, Notification } from "@scm-manager/ui-components";
import SinglePermission from "../containers/SinglePermission";
import { PermissionCollection, RepositoryRole } from "@scm-manager/ui-types";
import { Namespace, PermissionCollection, Repository, RepositoryRole } from "@scm-manager/ui-types";
type Props = {
availableRepositoryRoles: RepositoryRole[];
availableRoles: RepositoryRole[];
availableVerbs: string[];
namespace: string;
repoName?: string;
permissions: PermissionCollection;
namespaceOrRepository: Namespace | Repository;
};
const PermissionsTable: FC<Props> = ({
availableRepositoryRoles,
availableRoles,
availableVerbs,
namespace,
repoName,
permissions,
namespaceOrRepository,
permissions: permissionCollection
}) => {
const [t] = useTranslation("repos");
if (permissions?.length === 0) {
if (permissionCollection._embedded.permissions?.length === 0) {
return <Notification type="info">{t("permission.noPermissions")}</Notification>;
}
permissionCollection?._embedded.permissions.sort((a, b) => {
if (a.name > b.name) {
return 1;
} else if (a.name < b.name) {
return -1;
} else {
return 0;
}
});
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
@@ -69,14 +77,13 @@ const PermissionsTable: FC<Props> = ({
</tr>
</thead>
<tbody>
{permissions.map((permission) => {
{permissionCollection?._embedded.permissions.map(permission => {
return (
<SinglePermission
availableRepositoryRoles={availableRepositoryRoles}
availableRepositoryVerbs={availableVerbs}
availableRoles={availableRoles}
availableVerbs={availableVerbs}
key={permission.name + permission.groupPermission.toString()}
namespace={namespace}
repoName={repoName}
namespaceOrRepository={namespaceOrRepository}
permission={permission}
/>
);

View File

@@ -21,11 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { Select } from "@scm-manager/ui-components";
type Props = WithTranslation & {
type Props = {
availableRoles?: string[];
handleRoleChange: (p: string) => void;
role: string;
@@ -34,34 +33,36 @@ type Props = WithTranslation & {
loading?: boolean;
};
class RoleSelector extends React.Component<Props> {
render() {
const { availableRoles, role, handleRoleChange, loading, label, helpText } = this.props;
const emptyOption = {
label: "",
value: ""
};
if (!availableRoles) return null;
const createSelectOptions = (roles: string[]) => {
return roles.map(role => {
return {
label: role,
value: role
};
});
};
const options = role ? this.createSelectOptions(availableRoles) : ["", ...this.createSelectOptions(availableRoles)];
return (
<Select
onChange={handleRoleChange}
value={role ? role : ""}
options={options}
loading={loading}
label={label}
helpText={helpText}
/>
);
const RoleSelector: FC<Props> = ({ availableRoles, role, handleRoleChange, loading, label, helpText }) => {
if (!availableRoles) {
return null;
}
const options = role ? createSelectOptions(availableRoles) : [emptyOption, ...createSelectOptions(availableRoles)];
createSelectOptions(roles: string[]) {
return roles.map(role => {
return {
label: role,
value: role
};
});
}
}
return (
<Select
onChange={handleRoleChange}
value={role ? role : ""}
options={options}
loading={loading}
label={label}
helpText={helpText}
/>
);
};
export default withTranslation("repos")(RoleSelector);
export default RoleSelector;

View File

@@ -1,111 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import React, { FC } from "react";
// eslint-disable-next-line no-restricted-imports
import { mount, shallow } from "@scm-manager/ui-tests/enzyme-router";
// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/enzyme";
// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/i18n";
import DeletePermissionButton from "./DeletePermissionButton";
jest.mock("@scm-manager/ui-components", () => ({
ConfirmAlert: (({ children }) => <div className="modal">{children}</div>) as FC<never>,
DeleteButton: jest.requireActual("@scm-manager/ui-components").DeleteButton
}));
describe("DeletePermissionButton", () => {
it("should render nothing, if the delete link is missing", () => {
const permission = {
_links: {}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const navLink = shallow(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
expect(navLink.text()).toBe("");
});
it("should render the delete icon", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const deleteIcon = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
expect(deleteIcon.html()).not.toBe("");
});
it("should open the confirm dialog on button click", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const button = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
button.find(".fa-trash").simulate("click");
expect(button.find(".modal")).toBeTruthy();
});
it("should call the delete permission function with delete url", () => {
const permission = {
_links: {
delete: {
href: "/permission"
}
}
};
let calledUrl = null;
function capture(permission) {
calledUrl = permission._links.delete.href;
}
const button = mount(
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
<DeletePermissionButton permission={permission} confirmDialog={false} deletePermission={capture} />
);
button.find(".fa-trash").simulate("click");
expect(calledUrl).toBe("/permission");
});
});

View File

@@ -21,93 +21,80 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup, Modal, SubmitButton } from "@scm-manager/ui-components";
import PermissionCheckbox from "../../../permissions/components/PermissionCheckbox";
type Props = WithTranslation & {
readOnly: boolean;
type SelectedVerbs = { [verb: string]: boolean };
type Props = {
availableVerbs: string[];
selectedVerbs: string[];
onSubmit: (p: string[]) => void;
readOnly: boolean;
onSubmit: (verbs: string[]) => void;
onClose: () => void;
};
type State = {
verbs: any;
const createPreSelection = (availableVerbs: string[], selectedVerbs?: string[]): SelectedVerbs => {
const verbs: SelectedVerbs = {};
availableVerbs.forEach(verb => (verbs[verb] = selectedVerbs ? selectedVerbs.includes(verb) : false));
return verbs;
};
class AdvancedPermissionsDialog extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, readOnly, onSubmit, onClose }) => {
const [verbs, setVerbs] = useState<SelectedVerbs>({});
const [t] = useTranslation("repos");
useEffect(() => {
setVerbs(createPreSelection(availableVerbs, selectedVerbs));
}, [availableVerbs, selectedVerbs]);
const verbs = {};
props.availableVerbs.forEach(
verb => (verbs[verb] = props.selectedVerbs ? props.selectedVerbs.includes(verb) : false)
);
this.state = {
verbs
};
}
render() {
const { t, onClose, readOnly } = this.props;
const { verbs } = this.state;
const verbSelectBoxes = Object.entries(verbs).map(e => (
<PermissionCheckbox
key={e[0]}
name={e[0]}
checked={e[1]}
onChange={this.handleChange}
disabled={readOnly}
role={true}
/>
));
const submitButton = !readOnly ? <SubmitButton label={t("permission.advanced.dialog.submit")} /> : null;
const body = <>{verbSelectBoxes}</>;
const footer = (
<form onSubmit={this.onSubmit}>
<ButtonGroup>
{submitButton}
<Button label={t("permission.advanced.dialog.abort")} action={onClose} />
</ButtonGroup>
</form>
);
return (
<Modal
title={t("permission.advanced.dialog.title")}
closeFunction={() => onClose()}
body={body}
footer={footer}
active={true}
/>
);
}
handleChange = (value: boolean, name: string) => {
const { verbs } = this.state;
const newVerbs = {
...verbs,
[name]: value
};
this.setState({
verbs: newVerbs
});
const handleChange = (value: boolean, name?: string) => {
if (name) {
setVerbs({
...verbs,
[name]: value
});
}
};
onSubmit = () => {
this.props.onSubmit(
Object.entries(this.state.verbs)
.filter(e => e[1])
.map(e => e[0])
const verbSelectBoxes = Object.entries(verbs).map(([name, checked]) => (
<PermissionCheckbox
key={name}
name={name}
checked={checked}
onChange={handleChange}
disabled={readOnly}
role={true}
/>
));
const handleSubmit = () => {
onSubmit(
Object.entries(verbs)
.filter(([_, checked]) => checked)
.map(([name]) => name)
);
};
}
export default withTranslation("repos")(AdvancedPermissionsDialog);
const footer = (
<form onSubmit={handleSubmit}>
<ButtonGroup>
{!readOnly ? <SubmitButton label={t("permission.advanced.dialog.submit")} /> : null}
<Button label={t("permission.advanced.dialog.abort")} action={onClose} />
</ButtonGroup>
</form>
);
return (
<Modal
title={t("permission.advanced.dialog.title")}
closeFunction={() => onClose()}
body={<>{verbSelectBoxes}</>}
footer={footer}
active={true}
/>
);
};
export default AdvancedPermissionsDialog;

View File

@@ -21,11 +21,20 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { PermissionCollection, PermissionCreateEntry, RepositoryRole, SelectValue } from "@scm-manager/ui-types";
import React, { FC, FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Link,
Namespace,
PermissionCollection,
PermissionCreateEntry,
Repository,
RepositoryRole,
SelectValue
} from "@scm-manager/ui-types";
import {
Button,
ErrorNotification,
GroupAutocomplete,
LabelWithHelpIcon,
Level,
@@ -34,222 +43,199 @@ import {
Subtitle,
UserAutocomplete
} from "@scm-manager/ui-components";
import * as validator from "../components/permissionValidation";
import * as validator from "../utils/permissionValidation";
import RoleSelector from "../components/RoleSelector";
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
import { findVerbsForRole } from "../modules/permissions";
import { useCreatePermission, useIndexLinks } from "@scm-manager/ui-api";
import findVerbsForRole from "../utils/findVerbsForRole";
type Props = WithTranslation & {
type Props = {
availableRoles: RepositoryRole[];
availableVerbs: string[];
createPermission: (permission: PermissionCreateEntry) => void;
loading: boolean;
currentPermissions: PermissionCollection;
groupAutocompleteLink: string;
userAutocompleteLink: string;
namespaceOrRepository: Namespace | Repository;
};
type State = {
name: string;
role?: string;
verbs?: string[];
groupPermission: boolean;
type PermissionState = PermissionCreateEntry & {
valid: boolean;
value?: SelectValue;
showAdvancedDialog: boolean;
};
class CreatePermissionForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
name: "",
role: props.availableRoles[0].name,
verbs: undefined,
groupPermission: false,
valid: true,
value: undefined,
showAdvancedDialog: false
};
}
groupPermissionScopeChanged = (value: boolean) => {
if (value) {
this.permissionScopeChanged(true);
}
const useAutoCompleteLinks = () => {
const links = useIndexLinks()?.autocomplete as Link[];
return {
groups: links?.find(l => l.name === "groups"),
users: links?.find(l => l.name === "users")
};
};
userPermissionScopeChanged = (value: boolean) => {
if (value) {
this.permissionScopeChanged(false);
}
const CreatePermissionForm: FC<Props> = ({
availableRoles,
availableVerbs,
currentPermissions,
namespaceOrRepository
}) => {
const initialPermissionState = {
name: "",
role: availableRoles[0].name,
verbs: [],
groupPermission: false,
valid: false
};
const links = useAutoCompleteLinks();
const { isLoading, error, create, permission: createdPermission } = useCreatePermission(namespaceOrRepository);
const [showAdvancedDialog, setShowAdvancedDialog] = useState(false);
const [permission, setPermission] = useState<PermissionState>(initialPermissionState);
const [t] = useTranslation("repos");
useEffect(() => {
setPermission(initialPermissionState);
}, [createdPermission]);
const selectedVerbs = permission.role ? findVerbsForRole(availableRoles, permission.role) : permission.verbs;
permissionScopeChanged = (groupPermission: boolean) => {
this.setState({
value: undefined,
name: "",
groupPermission,
valid: false
});
};
renderAutocompletionField = () => {
const group = this.state.groupPermission;
if (group) {
return (
<GroupAutocomplete
autocompleteLink={this.props.groupAutocompleteLink}
valueSelected={this.selectName}
value={this.state.value ? this.state.value : ""}
/>
);
}
return (
<UserAutocomplete
autocompleteLink={this.props.userAutocompleteLink}
valueSelected={this.selectName}
value={this.state.value ? this.state.value : ""}
/>
);
};
selectName = (value: SelectValue) => {
this.setState({
const selectName = (value: SelectValue) => {
setPermission({
...permission,
value,
name: value.value.id,
valid: validator.isPermissionValid(value.value.id, this.state.groupPermission, this.props.currentPermissions)
valid: validator.isPermissionValid(
value.value.id,
permission.groupPermission,
currentPermissions._embedded.permissions
)
});
};
render() {
const { t, availableRoles, availableVerbs, loading } = this.props;
const { role, verbs, showAdvancedDialog } = this.state;
const availableRoleNames = availableRoles.map(r => r.name);
const selectedVerbs = role ? findVerbsForRole(availableRoles, role) : verbs;
const advancedDialog = showAdvancedDialog ? (
<AdvancedPermissionsDialog
availableVerbs={availableVerbs}
selectedVerbs={selectedVerbs}
onClose={this.toggleAdvancedPermissionsDialog}
onSubmit={this.submitAdvancedPermissionsDialog}
/>
) : null;
return (
<>
<hr />
<Subtitle subtitle={t("permission.add-permission.add-permission-heading")} />
{advancedDialog}
<form onSubmit={this.submit}>
<div className="field is-grouped">
<div className="control">
<Radio
name="permission_scope"
value="USER_PERMISSION"
checked={!this.state.groupPermission}
label={t("permission.user-permission")}
onChange={this.userPermissionScopeChanged}
/>
<Radio
name="permission_scope"
value="GROUP_PERMISSION"
checked={this.state.groupPermission}
label={t("permission.group-permission")}
onChange={this.groupPermissionScopeChanged}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">{this.renderAutocompletionField()}</div>
<div className="column is-half">
<div className="columns">
<div className="column is-narrow">
<RoleSelector
availableRoles={availableRoleNames}
label={t("permission.role")}
helpText={t("permission.help.roleHelpText")}
handleRoleChange={this.handleRoleChange}
role={role}
/>
</div>
<div className="column">
<LabelWithHelpIcon
label={t("permission.permissions")}
helpText={t("permission.help.permissionsHelpText")}
/>
<Button label={t("permission.advanced-button.label")} action={this.toggleAdvancedPermissionsDialog} />
</div>
</div>
</div>
</div>
<Level
right={
<SubmitButton
label={t("permission.add-permission.submit-button")}
loading={loading}
disabled={!this.state.valid || this.state.name === ""}
/>
}
/>
</form>
</>
);
}
toggleAdvancedPermissionsDialog = () => {
this.setState(prevState => ({
showAdvancedDialog: !prevState.showAdvancedDialog
}));
const groupPermissionScopeChanged = (value: boolean) => {
if (value) {
permissionScopeChanged(true);
}
};
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
this.setState({
showAdvancedDialog: false,
role: undefined,
verbs: newVerbs
const userPermissionScopeChanged = (value: boolean) => {
if (value) {
permissionScopeChanged(false);
}
};
const permissionScopeChanged = (groupPermission: boolean) => {
setPermission({
...permission,
groupPermission
});
};
submit = e => {
this.props.createPermission({
name: this.state.name,
role: this.state.role,
verbs: this.state.verbs,
groupPermission: this.state.groupPermission
});
this.removeState();
e.preventDefault();
};
removeState = () => {
this.setState({
name: "",
role: this.props.availableRoles[0].name,
verbs: undefined,
valid: true,
value: undefined
});
};
handleRoleChange = (role: string) => {
const selectedRole = this.findAvailableRole(role);
const handleRoleChange = (role: string) => {
const selectedRole = findAvailableRole(role);
if (!selectedRole) {
return;
}
this.setState({
role: selectedRole.name,
verbs: []
setPermission({
...permission,
verbs: [],
role
});
};
findAvailableRole = (roleName: string) => {
return this.props.availableRoles.find(role => role.name === roleName);
const submitAdvancedPermissionsDialog = (verbs: string[]) => {
setPermission({
...permission,
role: undefined,
verbs
});
setShowAdvancedDialog(false);
};
}
export default withTranslation("repos")(CreatePermissionForm);
const submit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
create(permission);
};
const findAvailableRole = (roleName: string) => {
return availableRoles.find(role => role.name === roleName);
};
return (
<>
<hr />
<Subtitle subtitle={t("permission.add-permission.add-permission-heading")} />
{showAdvancedDialog ? (
<AdvancedPermissionsDialog
availableVerbs={availableVerbs}
selectedVerbs={selectedVerbs || []}
onClose={() => setShowAdvancedDialog(false)}
onSubmit={submitAdvancedPermissionsDialog}
/>
) : null}
<ErrorNotification error={error} />
<form onSubmit={submit}>
<div className="field is-grouped">
<div className="control">
<Radio
name="permission_scope"
value="USER_PERMISSION"
checked={!permission.groupPermission}
label={t("permission.user-permission")}
onChange={userPermissionScopeChanged}
/>
<Radio
name="permission_scope"
value="GROUP_PERMISSION"
checked={permission.groupPermission}
label={t("permission.group-permission")}
onChange={groupPermissionScopeChanged}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
{permission.groupPermission && links.groups ? (
<GroupAutocomplete
autocompleteLink={links.groups.href}
valueSelected={selectName}
value={permission.value || ""}
/>
) : null}
{!permission.groupPermission && links.users ? (
<UserAutocomplete
autocompleteLink={links.users.href}
valueSelected={selectName}
value={permission.value || ""}
/>
) : null}
</div>
<div className="column is-half">
<div className="columns">
<div className="column is-narrow">
<RoleSelector
availableRoles={availableRoles.map(r => r.name)}
label={t("permission.role")}
helpText={t("permission.help.roleHelpText")}
handleRoleChange={handleRoleChange}
role={permission.role || ""}
/>
</div>
<div className="column">
<LabelWithHelpIcon
label={t("permission.permissions")}
helpText={t("permission.help.permissionsHelpText")}
/>
<Button label={t("permission.advanced-button.label")} action={() => setShowAdvancedDialog(true)} />
</div>
</div>
</div>
</div>
<Level
right={
<SubmitButton
label={t("permission.add-permission.submit-button")}
loading={isLoading}
disabled={!permission.valid || permission.name === ""}
/>
}
/>
</form>
</>
);
};
export default CreatePermissionForm;

View File

@@ -21,218 +21,60 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import {
createPermission,
createPermissionReset,
deletePermissionReset,
fetchAvailablePermissionsIfNeeded,
fetchPermissions,
getAvailablePermissions,
getAvailableRepositoryRoles,
getAvailableRepositoryVerbs,
getCreatePermissionFailure,
getDeletePermissionsFailure,
getFetchAvailablePermissionsFailure,
getFetchPermissionsFailure,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
isCreatePermissionPending,
isFetchAvailablePermissionsPending,
isFetchPermissionsPending,
modifyPermissionReset
} from "../modules/permissions";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { ErrorPage, Loading, Subtitle } from "@scm-manager/ui-components";
import { Permission, PermissionCollection, PermissionCreateEntry, RepositoryRole } from "@scm-manager/ui-types";
import { Namespace, Repository } from "@scm-manager/ui-types";
import CreatePermissionForm from "./CreatePermissionForm";
import { History } from "history";
import { getPermissionsLink } from "../../modules/repos";
import {
getGroupAutoCompleteLink,
getRepositoryRolesLink,
getRepositoryVerbsLink,
getUserAutoCompleteLink
} from "../../../modules/indexResource";
import PermissionsTable from "../components/PermissionsTable";
type Props = WithTranslation & {
availablePermissions: boolean;
availableRepositoryRoles: RepositoryRole[];
availableVerbs: string[];
namespace: string;
repoName?: string;
loading: boolean;
error: Error;
permissions: PermissionCollection;
hasPermissionToCreate: boolean;
loadingCreatePermission: boolean;
repositoryRolesLink: string;
repositoryVerbsLink: string;
permissionsLink: string;
groupAutocompleteLink: string;
userAutocompleteLink: string;
import { useAvailablePermissions, usePermissions } from "@scm-manager/ui-api";
// dispatch functions
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void;
fetchPermissions: (link: string, namespace: string, repoName?: string) => void;
createPermission: (
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName?: string,
callback?: () => void
) => void;
createPermissionReset: (namespace: string, repoName?: string) => void;
modifyPermissionReset: (namespace: string, repoName?: string) => void;
deletePermissionReset: (namespace: string, repoName?: string) => void;
// context props
match: any;
history: History;
type Props = {
namespaceOrRepository: Namespace | Repository;
};
class Permissions extends React.Component<Props> {
componentDidMount() {
const {
fetchAvailablePermissionsIfNeeded,
fetchPermissions,
namespace,
repoName,
modifyPermissionReset,
createPermissionReset,
deletePermissionReset,
permissionsLink,
repositoryRolesLink,
repositoryVerbsLink
} = this.props;
const usePermissionData = (namespaceOrRepository: Namespace | Repository) => {
const permissions = usePermissions(namespaceOrRepository);
const availablePermissions = useAvailablePermissions();
return {
isLoading: permissions.isLoading || availablePermissions.isLoading,
error: permissions.error || availablePermissions.error,
permissions: permissions.data,
availablePermissions: availablePermissions.data
};
};
createPermissionReset(namespace, repoName);
modifyPermissionReset(namespace, repoName);
deletePermissionReset(namespace, repoName);
fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink);
fetchPermissions(permissionsLink, namespace, repoName);
const Permissions: FC<Props> = ({ namespaceOrRepository }) => {
const { isLoading, error, permissions, availablePermissions } = usePermissionData(namespaceOrRepository);
const [t] = useTranslation("repos");
if (error) {
return <ErrorPage title={t("permission.error-title")} subtitle={t("permission.error-subtitle")} error={error} />;
}
createPermission = (permission: Permission) => {
this.props.createPermission(this.props.permissionsLink, permission, this.props.namespace, this.props.repoName);
};
if (isLoading || !permissions || !availablePermissions) {
return <Loading />;
}
render() {
const {
availablePermissions,
availableRepositoryRoles,
availableVerbs,
loading,
error,
permissions,
t,
namespace,
repoName,
loadingCreatePermission,
hasPermissionToCreate,
userAutocompleteLink,
groupAutocompleteLink
} = this.props;
if (error) {
return <ErrorPage title={t("permission.error-title")} subtitle={t("permission.error-subtitle")} error={error} />;
}
if (loading || !permissions || !availablePermissions) {
return <Loading />;
}
const createPermissionForm = hasPermissionToCreate ? (
<CreatePermissionForm
availableRoles={availableRepositoryRoles}
availableVerbs={availableVerbs}
createPermission={(permission) => this.createPermission(permission)}
loading={loadingCreatePermission}
currentPermissions={permissions}
userAutocompleteLink={userAutocompleteLink}
groupAutocompleteLink={groupAutocompleteLink}
return (
<div>
<Subtitle subtitle={t("permission.title")} />
<PermissionsTable
availableRoles={availablePermissions.repositoryRoles}
availableVerbs={availablePermissions.repositoryVerbs}
permissions={permissions}
namespaceOrRepository={namespaceOrRepository}
/>
) : null;
return (
<div>
<Subtitle subtitle={t("permission.title")} />
<PermissionsTable {...this.props} />
{createPermissionForm}
</div>
);
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const namespace = ownProps.namespace;
const repoName = ownProps.repoName;
const error =
getFetchPermissionsFailure(state, namespace, repoName) ||
getCreatePermissionFailure(state, namespace, repoName) ||
getDeletePermissionsFailure(state, namespace, repoName) ||
getModifyPermissionsFailure(state, namespace, repoName) ||
getFetchAvailablePermissionsFailure(state);
const loading = isFetchPermissionsPending(state, namespace, repoName) || isFetchAvailablePermissionsPending(state);
const permissions = getPermissionsOfRepo(state, namespace, repoName);
const loadingCreatePermission = isCreatePermissionPending(state, namespace, repoName);
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
const repositoryRolesLink = getRepositoryRolesLink(state);
const repositoryVerbsLink = getRepositoryVerbsLink(state);
const permissionsLink = getPermissionsLink(state, namespace, repoName);
const groupAutocompleteLink = getGroupAutoCompleteLink(state);
const userAutocompleteLink = getUserAutoCompleteLink(state);
const availablePermissions = getAvailablePermissions(state);
const availableRepositoryRoles = getAvailableRepositoryRoles(state);
const availableVerbs = getAvailableRepositoryVerbs(state);
return {
availablePermissions,
availableRepositoryRoles,
availableVerbs,
namespace,
repoName,
repositoryRolesLink,
repositoryVerbsLink,
error,
loading,
permissions,
hasPermissionToCreate,
loadingCreatePermission,
permissionsLink,
groupAutocompleteLink,
userAutocompleteLink
};
{permissions?._links.create ? (
<CreatePermissionForm
availableRoles={availablePermissions.repositoryRoles}
availableVerbs={availablePermissions.repositoryVerbs}
currentPermissions={permissions}
namespaceOrRepository={namespaceOrRepository}
/>
) : null}
</div>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchPermissions: (link: string, namespace: string, repoName?: string) => {
dispatch(fetchPermissions(link, namespace, repoName));
},
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => {
dispatch(fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink));
},
createPermission: (
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
) => {
dispatch(createPermission(link, permission, namespace, repoName, callback));
},
createPermissionReset: (namespace: string, repoName?: string) => {
dispatch(createPermissionReset(namespace, repoName));
},
modifyPermissionReset: (namespace: string, repoName?: string) => {
dispatch(modifyPermissionReset(namespace, repoName));
},
deletePermissionReset: (namespace: string, repoName?: string) => {
dispatch(deletePermissionReset(namespace, repoName));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("repos")(Permissions));
export default Permissions;

View File

@@ -21,43 +21,16 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { History } from "history";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Permission, RepositoryRole } from "@scm-manager/ui-types";
import { Button, Icon } from "@scm-manager/ui-components";
import {
deletePermission,
findVerbsForRole,
isDeletePermissionPending,
isModifyPermissionPending,
modifyPermission
} from "../modules/permissions";
import DeletePermissionButton from "../components/buttons/DeletePermissionButton";
import { Namespace, Permission, Repository, RepositoryRole } from "@scm-manager/ui-types";
import { Button, ErrorNotification, Icon } from "@scm-manager/ui-components";
import DeletePermissionButton from "../components/DeletePermissionButton";
import RoleSelector from "../components/RoleSelector";
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
type Props = WithTranslation & {
availableRepositoryRoles: RepositoryRole[];
availableRepositoryVerbs: string[];
submitForm: (p: Permission) => void;
modifyPermission: (permission: Permission, namespace: string, name?: string) => void;
permission: Permission;
namespace: string;
repoName?: string;
match: any;
history: History;
loading: boolean;
deletePermission: (permission: Permission, namespace: string, name?: string) => void;
deleteLoading: boolean;
};
type State = {
permission: Permission;
showAdvancedDialog: boolean;
};
import { useUpdatePermission } from "@scm-manager/ui-api";
import findVerbsForRole from "../utils/findVerbsForRole";
const FullWidthTr = styled.tr`
width: 100%;
@@ -68,188 +41,98 @@ const VCenteredTd = styled.td`
vertical-align: middle !important;
`;
class SinglePermission extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
type Props = {
namespaceOrRepository: Namespace | Repository;
availableRoles: RepositoryRole[];
availableVerbs: string[];
permission: Permission;
};
const defaultPermission = props.availableRepositoryRoles ? props.availableRepositoryRoles[0] : {};
const PermissionIcon: FC<{ permission: Permission }> = ({ permission }) => {
const [t] = useTranslation("repos");
if (permission.groupPermission) {
return <Icon title={t("permission.group")} name="user-friends" />;
} else {
return <Icon title={t("permission.user")} name="user" />;
}
};
this.state = {
permission: {
name: "",
role: undefined,
verbs: defaultPermission.verbs,
groupPermission: false,
_links: {}
},
showAdvancedDialog: false
const SinglePermission: FC<Props> = ({
namespaceOrRepository,
availableRoles,
availableVerbs,
permission: defaultPermission
}) => {
const [permission, setPermission] = useState(defaultPermission);
const [showAdvancedDialog, setShowAdvancedDialog] = useState(false);
const { isLoading, error, update } = useUpdatePermission(namespaceOrRepository);
const [t] = useTranslation("repos");
useEffect(() => {
setPermission(defaultPermission);
}, [defaultPermission]);
const availableRoleNames = !!availableRoles && availableRoles.map(r => r.name);
const readOnly = !permission._links.update;
const selectedVerbs = permission.role ? findVerbsForRole(availableRoles, permission.role) : permission.verbs;
const handleRoleChange = (role: string) => {
const newPermission = {
...permission,
verbs: [],
role
};
}
componentDidMount() {
const { permission } = this.props;
if (permission) {
this.setState({
permission: {
name: permission.name,
role: permission.role,
verbs: permission.verbs,
groupPermission: permission.groupPermission,
_links: permission._links
}
});
}
}
deletePermission = () => {
this.props.deletePermission(this.props.permission, this.props.namespace, this.props.repoName);
setPermission(newPermission);
update(newPermission);
};
render() {
const { availableRepositoryRoles, availableRepositoryVerbs, loading, namespace, repoName, t } = this.props;
const { permission, showAdvancedDialog } = this.state;
const availableRoleNames = !!availableRepositoryRoles && availableRepositoryRoles.map(r => r.name);
const readOnly = !this.mayChangePermissions();
const roleSelector = readOnly ? (
<td>{permission.role ? permission.role : t("permission.custom")}</td>
) : (
<td>
<RoleSelector
handleRoleChange={this.handleRoleChange}
availableRoles={availableRoleNames}
role={permission.role}
loading={loading}
/>
</td>
);
const handleVerbsChange = (verbs: string[]) => {
const newPermission = {
...permission,
role: undefined,
verbs
};
setPermission(newPermission);
update(newPermission);
setShowAdvancedDialog(false);
};
const selectedVerbs = permission.role
? findVerbsForRole(availableRepositoryRoles, permission.role)
: permission.verbs;
const advancedDialog = showAdvancedDialog ? (
<AdvancedPermissionsDialog
readOnly={readOnly}
availableVerbs={availableRepositoryVerbs}
selectedVerbs={selectedVerbs}
onClose={this.closeAdvancedPermissionsDialog}
onSubmit={this.submitAdvancedPermissionsDialog}
/>
) : null;
const iconType =
permission && permission.groupPermission ? (
<Icon title={t("permission.group")} name="user-friends" />
return (
<FullWidthTr>
<VCenteredTd>
<PermissionIcon permission={permission} /> {permission.name}
<ErrorNotification error={error} />
</VCenteredTd>
{readOnly ? (
<td>{permission.role ? permission.role : t("permission.custom")}</td>
) : (
<Icon title={t("permission.user")} name="user" />
);
return (
<FullWidthTr>
<VCenteredTd>
{iconType} {permission.name}
</VCenteredTd>
{roleSelector}
<VCenteredTd>
<Button label={t("permission.advanced-button.label")} action={this.handleDetailedPermissionsPressed} />
</VCenteredTd>
<VCenteredTd className="is-darker">
<DeletePermissionButton
permission={permission}
namespace={namespace}
repoName={repoName}
deletePermission={this.deletePermission}
loading={this.props.deleteLoading}
<td>
<RoleSelector
handleRoleChange={handleRoleChange}
availableRoles={availableRoleNames}
role={permission.role || ""}
loading={isLoading}
/>
{advancedDialog}
</VCenteredTd>
</FullWidthTr>
);
}
mayChangePermissions = () => {
return this.props.permission._links && this.props.permission._links.update;
};
handleDetailedPermissionsPressed = () => {
this.setState({
showAdvancedDialog: true
});
};
closeAdvancedPermissionsDialog = () => {
this.setState({
showAdvancedDialog: false
});
};
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
const { permission } = this.state;
this.setState(
{
showAdvancedDialog: false,
permission: {
...permission,
role: undefined,
verbs: newVerbs
}
},
() => this.modifyPermissionVerbs(newVerbs)
);
};
handleRoleChange = (role: string) => {
const { permission } = this.state;
this.setState(
{
permission: {
...permission,
role: role,
verbs: undefined
}
},
() => this.modifyPermissionRole(role)
);
};
findAvailableRole = (roleName: string) => {
const { availableRepositoryRoles } = this.props;
return availableRepositoryRoles.find(role => role.name === roleName);
};
modifyPermissionRole = (role: string) => {
const permission = this.state.permission;
permission.role = role;
this.props.modifyPermission(permission, this.props.namespace, this.props.repoName);
};
modifyPermissionVerbs = (verbs: string[]) => {
const permission = this.state.permission;
permission.verbs = verbs;
this.props.modifyPermission(permission, this.props.namespace, this.props.repoName);
};
}
const mapStateToProps = (state: any, ownProps: Props) => {
const permission = ownProps.permission;
const loading = isModifyPermissionPending(state, ownProps.namespace, ownProps.repoName, permission);
const deleteLoading = isDeletePermissionPending(state, ownProps.namespace, ownProps.repoName, permission);
return {
loading,
deleteLoading
};
</td>
)}
<VCenteredTd>
<Button label={t("permission.advanced-button.label")} action={() => setShowAdvancedDialog(true)} />
</VCenteredTd>
<VCenteredTd className="is-darker">
{permission._links.delete ? (
<DeletePermissionButton permission={permission} namespaceOrRepository={namespaceOrRepository} />
) : null}
{showAdvancedDialog ? (
<AdvancedPermissionsDialog
readOnly={readOnly}
availableVerbs={availableVerbs}
selectedVerbs={selectedVerbs || []}
onClose={() => setShowAdvancedDialog(false)}
onSubmit={handleVerbsChange}
/>
) : null}
</VCenteredTd>
</FullWidthTr>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
modifyPermission: (permission: Permission, namespace: string, repoName: string) => {
dispatch(modifyPermission(permission, namespace, repoName));
},
deletePermission: (permission: Permission, namespace: string, repoName: string) => {
dispatch(deletePermission(permission, namespace, repoName));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("repos")(SinglePermission));
export default SinglePermission;

View File

@@ -1,659 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
CREATE_PERMISSION,
CREATE_PERMISSION_FAILURE,
CREATE_PERMISSION_PENDING,
CREATE_PERMISSION_SUCCESS,
createPermission,
createPermissionSuccess,
DELETE_PERMISSION,
DELETE_PERMISSION_FAILURE,
DELETE_PERMISSION_PENDING,
DELETE_PERMISSION_SUCCESS,
deletePermission,
deletePermissionSuccess,
FETCH_PERMISSIONS,
FETCH_PERMISSIONS_FAILURE,
FETCH_PERMISSIONS_PENDING,
FETCH_PERMISSIONS_SUCCESS,
fetchPermissions,
fetchPermissionsSuccess,
getCreatePermissionFailure,
getDeletePermissionFailure,
getDeletePermissionsFailure,
getFetchPermissionsFailure,
getModifyPermissionFailure,
getModifyPermissionsFailure,
getPermissionsOfRepo,
hasCreatePermission,
isCreatePermissionPending,
isDeletePermissionPending,
isFetchPermissionsPending,
isModifyPermissionPending,
MODIFY_PERMISSION,
MODIFY_PERMISSION_FAILURE,
MODIFY_PERMISSION_PENDING,
MODIFY_PERMISSION_SUCCESS,
modifyPermission,
modifyPermissionSuccess
} from "./permissions";
import { Permission, PermissionCollection } from "@scm-manager/ui-types";
const hitchhiker_puzzle42Permission_user_eins: Permission = {
name: "user_eins",
type: "READ",
groupPermission: false,
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
},
update: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins"
}
},
verbs: []
};
const hitchhiker_puzzle42Permission_user_zwei: Permission = {
name: "user_zwei",
type: "WRITE",
groupPermission: true,
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
},
update: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei"
}
},
verbs: []
};
const hitchhiker_puzzle42Permissions: PermissionCollection = [
hitchhiker_puzzle42Permission_user_eins,
hitchhiker_puzzle42Permission_user_zwei
];
const hitchhiker_puzzle42RepoPermissions = {
_embedded: {
permissions: hitchhiker_puzzle42Permissions
},
_links: {
create: {
href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions"
}
}
};
describe("permission fetch", () => {
const REPOS_URL = "/api/v2/repositories";
const URL = "repositories";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch permissions to repo hitchhiker/puzzle42", () => {
fetchMock.getOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", hitchhiker_puzzle42RepoPermissions);
const expectedActions = [
{
type: FETCH_PERMISSIONS_PENDING,
payload: {
namespace: "hitchhiker",
repoName: "puzzle42"
},
itemId: "hitchhiker/puzzle42"
},
{
type: FETCH_PERMISSIONS_SUCCESS,
payload: hitchhiker_puzzle42RepoPermissions,
itemId: "hitchhiker/puzzle42"
}
];
const store = mockStore({});
return store
.dispatch(fetchPermissions(URL + "/hitchhiker/puzzle42/permissions", "hitchhiker", "puzzle42"))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PERMISSIONS_FAILURE, it the request fails", () => {
fetchMock.getOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 500
});
const store = mockStore({});
return store
.dispatch(fetchPermissions(URL + "/hitchhiker/puzzle42/permissions", "hitchhiker", "puzzle42"))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PERMISSIONS_PENDING);
expect(actions[1].type).toEqual(FETCH_PERMISSIONS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully modify user_eins permission", () => {
fetchMock.putOnce(hitchhiker_puzzle42Permission_user_eins._links.update.href, {
status: 204
});
const editedPermission = {
...hitchhiker_puzzle42Permission_user_eins,
type: "OWNER"
};
const store = mockStore({});
return store.dispatch(modifyPermission(editedPermission, "hitchhiker", "puzzle42")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_SUCCESS);
});
});
it("should successfully modify user_eins permission and call the callback", () => {
fetchMock.putOnce(hitchhiker_puzzle42Permission_user_eins._links.update.href, {
status: 204
});
const editedPermission = {
...hitchhiker_puzzle42Permission_user_eins,
type: "OWNER"
};
const store = mockStore({});
let called = false;
const callback = () => {
called = true;
};
return store.dispatch(modifyPermission(editedPermission, "hitchhiker", "puzzle42", callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_SUCCESS);
expect(called).toBe(true);
});
});
it("should fail modifying on HTTP 500", () => {
fetchMock.putOnce(hitchhiker_puzzle42Permission_user_eins._links.update.href, {
status: 500
});
const editedPermission = {
...hitchhiker_puzzle42Permission_user_eins,
type: "OWNER"
};
const store = mockStore({});
return store.dispatch(modifyPermission(editedPermission, "hitchhiker", "puzzle42")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_PERMISSION_PENDING);
expect(actions[1].type).toEqual(MODIFY_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should add a permission successfully", () => {
// unmatched
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 204,
headers: {
location: "repositories/hitchhiker/puzzle42/permissions/user_eins"
}
});
fetchMock.getOnce(
REPOS_URL + "/hitchhiker/puzzle42/permissions/user_eins",
hitchhiker_puzzle42Permission_user_eins
);
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_PERMISSION_PENDING);
expect(actions[1].type).toEqual(CREATE_PERMISSION_SUCCESS);
});
});
it("should fail adding a permission on HTTP 500", () => {
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 500
});
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
)
)
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_PERMISSION_PENDING);
expect(actions[1].type).toEqual(CREATE_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should call the callback after permission successfully created", () => {
// unmatched
fetchMock.postOnce(REPOS_URL + "/hitchhiker/puzzle42/permissions", {
status: 204,
headers: {
location: "repositories/hitchhiker/puzzle42/permissions/user_eins"
}
});
fetchMock.getOnce(
REPOS_URL + "/hitchhiker/puzzle42/permissions/user_eins",
hitchhiker_puzzle42Permission_user_eins
);
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store
.dispatch(
createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42",
callback
)
)
.then(() => {
expect(callMe).toBe("yeah");
});
});
it("should delete successfully permission user_eins", () => {
fetchMock.deleteOnce(hitchhiker_puzzle42Permission_user_eins._links.delete.href, {
status: 204
});
const store = mockStore({});
return store
.dispatch(deletePermission(hitchhiker_puzzle42Permission_user_eins, "hitchhiker", "puzzle42"))
.then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_PERMISSION_PENDING);
expect(actions[0].payload).toBe(hitchhiker_puzzle42Permission_user_eins);
expect(actions[1].type).toEqual(DELETE_PERMISSION_SUCCESS);
});
});
it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce(hitchhiker_puzzle42Permission_user_eins._links.delete.href, {
status: 204
});
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store
.dispatch(deletePermission(hitchhiker_puzzle42Permission_user_eins, "hitchhiker", "puzzle42", callMe))
.then(() => {
expect(called).toBeTruthy();
});
});
it("should fail to delete permission", () => {
fetchMock.deleteOnce(hitchhiker_puzzle42Permission_user_eins._links.delete.href, {
status: 500
});
const store = mockStore({});
return store
.dispatch(deletePermission(hitchhiker_puzzle42Permission_user_eins, "hitchhiker", "puzzle42"))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_PERMISSION_PENDING);
expect(actions[0].payload).toBe(hitchhiker_puzzle42Permission_user_eins);
expect(actions[1].type).toEqual(DELETE_PERMISSION_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("permissions reducer", () => {
it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
it("should return the same state, if the action is undefined", () => {
const state = {
x: true
};
expect(reducer(state)).toBe(state);
});
it("should return the same state, if the action is unknown to the reducer", () => {
const state = {
x: true
};
expect(
reducer(state, {
type: "EL_SPECIALE"
})
).toBe(state);
});
it("should store the permissions on FETCH_PERMISSION_SUCCESS", () => {
const newState = reducer({}, fetchPermissionsSuccess(hitchhiker_puzzle42RepoPermissions, "hitchhiker", "puzzle42"));
expect(newState["hitchhiker/puzzle42"].entries).toBe(hitchhiker_puzzle42Permissions);
});
it("should update permission", () => {
const oldState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins]
}
};
const permissionEdited = {
...hitchhiker_puzzle42Permission_user_eins,
type: "OWNER"
};
const expectedState = {
"hitchhiker/puzzle42": {
entries: [permissionEdited]
}
};
const newState = reducer(oldState, modifyPermissionSuccess(permissionEdited, "hitchhiker", "puzzle42"));
expect(newState["hitchhiker/puzzle42"]).toEqual(expectedState["hitchhiker/puzzle42"]);
});
it("should remove permission from state when delete succeeds", () => {
const state = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins, hitchhiker_puzzle42Permission_user_zwei]
}
};
const expectedState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_zwei]
}
};
const newState = reducer(
state,
deletePermissionSuccess(hitchhiker_puzzle42Permission_user_eins, "hitchhiker", "puzzle42")
);
expect(newState["hitchhiker/puzzle42"]).toEqual(expectedState["hitchhiker/puzzle42"]);
});
it("should add permission", () => {
//changing state had to be removed because of errors
const oldState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins]
}
};
const expectedState = {
"hitchhiker/puzzle42": {
entries: [hitchhiker_puzzle42Permission_user_eins, hitchhiker_puzzle42Permission_user_zwei]
}
};
const newState = reducer(
oldState,
createPermissionSuccess(hitchhiker_puzzle42Permission_user_zwei, "hitchhiker", "puzzle42")
);
expect(newState["hitchhiker/puzzle42"]).toEqual(expectedState["hitchhiker/puzzle42"]);
});
});
describe("permissions selectors", () => {
const error = new Error("something goes wrong");
it("should return the permissions of one repository", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
entries: hitchhiker_puzzle42Permissions
}
}
};
const repoPermissions = getPermissionsOfRepo(state, "hitchhiker", "puzzle42");
expect(repoPermissions).toEqual(hitchhiker_puzzle42Permissions);
});
it("should return true, when fetch permissions is pending", () => {
const state = {
pending: {
[FETCH_PERMISSIONS + "/hitchhiker/puzzle42"]: true
}
};
expect(isFetchPermissionsPending(state, "hitchhiker", "puzzle42")).toEqual(true);
});
it("should return false, when fetch permissions is not pending", () => {
expect(isFetchPermissionsPending({}, "hitchiker", "puzzle42")).toEqual(false);
});
it("should return error when fetch permissions did fail", () => {
const state = {
failure: {
[FETCH_PERMISSIONS + "/hitchhiker/puzzle42"]: error
}
};
expect(getFetchPermissionsFailure(state, "hitchhiker", "puzzle42")).toEqual(error);
});
it("should return undefined when fetch permissions did not fail", () => {
expect(getFetchPermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(undefined);
});
it("should return true, when modify permission is pending", () => {
const state = {
pending: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: true
}
};
expect(isModifyPermissionPending(state, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toEqual(
true
);
});
it("should return false, when modify permission is not pending", () => {
expect(isModifyPermissionPending({}, "hitchiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toEqual(
false
);
});
it("should return error when modify permission did fail", () => {
const state = {
failure: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getModifyPermissionFailure(state, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)
).toEqual(error);
});
it("should return undefined when modify permission did not fail", () => {
expect(getModifyPermissionFailure({}, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toBe(
undefined
);
});
it("should return error when one of the modify permissions did fail", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
entries: hitchhiker_puzzle42Permissions
}
},
failure: {
[MODIFY_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(getModifyPermissionsFailure(state, "hitchhiker", "puzzle42")).toEqual(error);
});
it("should return undefined when no modify permissions did not fail", () => {
expect(getModifyPermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(undefined);
});
it("should return true, when createPermission is true", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
createPermission: true
}
}
};
expect(hasCreatePermission(state, "hitchhiker", "puzzle42")).toBe(true);
});
it("should return false, when createPermission is false", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
createPermission: false
}
}
};
expect(hasCreatePermission(state, "hitchhiker", "puzzle42")).toEqual(false);
});
it("should return true, when delete permission is pending", () => {
const state = {
pending: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: true
}
};
expect(isDeletePermissionPending(state, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toEqual(
true
);
});
it("should return false, when delete permission is not pending", () => {
expect(isDeletePermissionPending({}, "hitchiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toEqual(
false
);
});
it("should return error when delete permission did fail", () => {
const state = {
failure: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(
getDeletePermissionFailure(state, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)
).toEqual(error);
});
it("should return undefined when delete permission did not fail", () => {
expect(getDeletePermissionFailure({}, "hitchhiker", "puzzle42", hitchhiker_puzzle42Permission_user_eins)).toBe(
undefined
);
});
it("should return error when one of the delete permissions did fail", () => {
const state = {
permissions: {
"hitchhiker/puzzle42": {
entries: hitchhiker_puzzle42Permissions
}
},
failure: {
[DELETE_PERMISSION + "/hitchhiker/puzzle42/user_eins"]: error
}
};
expect(getDeletePermissionsFailure(state, "hitchhiker", "puzzle42")).toEqual(error);
});
it("should return undefined when no delete permissions did not fail", () => {
expect(getDeletePermissionsFailure({}, "hitchhiker", "puzzle42")).toBe(undefined);
});
it("should return true, when create permission is pending", () => {
const state = {
pending: {
[CREATE_PERMISSION + "/hitchhiker/puzzle42"]: true
}
};
expect(isCreatePermissionPending(state, "hitchhiker", "puzzle42")).toEqual(true);
});
it("should return false, when create permissions is not pending", () => {
expect(isCreatePermissionPending({}, "hitchiker", "puzzle42")).toEqual(false);
});
it("should return error when create permissions did fail", () => {
const state = {
failure: {
[CREATE_PERMISSION + "/hitchhiker/puzzle42"]: error
}
};
expect(getCreatePermissionFailure(state, "hitchhiker", "puzzle42")).toEqual(error);
});
it("should return undefined when create permissions did not fail", () => {
expect(getCreatePermissionFailure({}, "hitchhiker", "puzzle42")).toBe(undefined);
});
});

View File

@@ -1,597 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Action, apiClient } from "@scm-manager/ui-components";
import * as types from "../../../modules/types";
import { Permission, PermissionCollection, PermissionCreateEntry, RepositoryRole } from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import { Dispatch } from "redux";
export const FETCH_AVAILABLE = "scm/permissions/FETCH_AVAILABLE";
export const FETCH_AVAILABLE_PENDING = `${FETCH_AVAILABLE}_${types.PENDING_SUFFIX}`;
export const FETCH_AVAILABLE_SUCCESS = `${FETCH_AVAILABLE}_${types.SUCCESS_SUFFIX}`;
export const FETCH_AVAILABLE_FAILURE = `${FETCH_AVAILABLE}_${types.FAILURE_SUFFIX}`;
export const FETCH_PERMISSIONS = "scm/permissions/FETCH_PERMISSIONS";
export const FETCH_PERMISSIONS_PENDING = `${FETCH_PERMISSIONS}_${types.PENDING_SUFFIX}`;
export const FETCH_PERMISSIONS_SUCCESS = `${FETCH_PERMISSIONS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_PERMISSIONS_FAILURE = `${FETCH_PERMISSIONS}_${types.FAILURE_SUFFIX}`;
export const MODIFY_PERMISSION = "scm/permissions/MODFIY_PERMISSION";
export const MODIFY_PERMISSION_PENDING = `${MODIFY_PERMISSION}_${types.PENDING_SUFFIX}`;
export const MODIFY_PERMISSION_SUCCESS = `${MODIFY_PERMISSION}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_PERMISSION_FAILURE = `${MODIFY_PERMISSION}_${types.FAILURE_SUFFIX}`;
export const MODIFY_PERMISSION_RESET = `${MODIFY_PERMISSION}_${types.RESET_SUFFIX}`;
export const CREATE_PERMISSION = "scm/permissions/CREATE_PERMISSION";
export const CREATE_PERMISSION_PENDING = `${CREATE_PERMISSION}_${types.PENDING_SUFFIX}`;
export const CREATE_PERMISSION_SUCCESS = `${CREATE_PERMISSION}_${types.SUCCESS_SUFFIX}`;
export const CREATE_PERMISSION_FAILURE = `${CREATE_PERMISSION}_${types.FAILURE_SUFFIX}`;
export const CREATE_PERMISSION_RESET = `${CREATE_PERMISSION}_${types.RESET_SUFFIX}`;
export const DELETE_PERMISSION = "scm/permissions/DELETE_PERMISSION";
export const DELETE_PERMISSION_PENDING = `${DELETE_PERMISSION}_${types.PENDING_SUFFIX}`;
export const DELETE_PERMISSION_SUCCESS = `${DELETE_PERMISSION}_${types.SUCCESS_SUFFIX}`;
export const DELETE_PERMISSION_FAILURE = `${DELETE_PERMISSION}_${types.FAILURE_SUFFIX}`;
export const DELETE_PERMISSION_RESET = `${DELETE_PERMISSION}_${types.RESET_SUFFIX}`;
const CONTENT_TYPE = "application/vnd.scmm-repositoryPermission+json";
// fetch available permissions
export function fetchAvailablePermissionsIfNeeded(repositoryRolesLink: string, repositoryVerbsLink: string) {
return function(dispatch: any, getState: () => object) {
if (shouldFetchAvailablePermissions(getState())) {
return fetchAvailablePermissions(dispatch, getState, repositoryRolesLink, repositoryVerbsLink);
}
};
}
export function fetchAvailablePermissions(
dispatch: any,
getState: () => object,
repositoryRolesLink: string,
repositoryVerbsLink: string
) {
dispatch(fetchAvailablePending());
return apiClient
.get(repositoryRolesLink)
.then(repositoryRoles => repositoryRoles.json())
.then(repositoryRoles => repositoryRoles._embedded.repositoryRoles)
.then(repositoryRoles => {
return apiClient
.get(repositoryVerbsLink)
.then(repositoryVerbs => repositoryVerbs.json())
.then(repositoryVerbs => repositoryVerbs.verbs)
.then(repositoryVerbs => {
return {
repositoryVerbs,
repositoryRoles
};
});
})
.then(available => {
dispatch(fetchAvailableSuccess(available));
})
.catch(err => {
dispatch(fetchAvailableFailure(err));
});
}
export function shouldFetchAvailablePermissions(state: object) {
if (isFetchAvailablePermissionsPending(state) || getFetchAvailablePermissionsFailure(state)) {
return false;
}
return !state.available;
}
export function fetchAvailablePending(): Action {
return {
type: FETCH_AVAILABLE_PENDING,
payload: {},
itemId: "available"
};
}
export function fetchAvailableSuccess(available: [RepositoryRole[], string[]]): Action {
return {
type: FETCH_AVAILABLE_SUCCESS,
payload: available,
itemId: "available"
};
}
export function fetchAvailableFailure(error: Error): Action {
return {
type: FETCH_AVAILABLE_FAILURE,
payload: {
error
},
itemId: "available"
};
}
// fetch permissions
export function fetchPermissions(link: string, namespace: string, repoName?: string) {
return function(dispatch: any) {
dispatch(fetchPermissionsPending(namespace, repoName));
return apiClient
.get(link)
.then(response => response.json())
.then(permissions => {
dispatch(fetchPermissionsSuccess(permissions, namespace, repoName));
})
.catch(err => {
dispatch(fetchPermissionsFailure(namespace, repoName, err));
});
};
}
export function fetchPermissionsPending(namespace: string, repoName?: string): Action {
return {
type: FETCH_PERMISSIONS_PENDING,
payload: {
namespace,
repoName
},
itemId: createPermissionStateKey(namespace, repoName)
};
}
export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName?: string): Action {
return {
type: FETCH_PERMISSIONS_SUCCESS,
payload: permissions,
itemId: createPermissionStateKey(namespace, repoName)
};
}
export function fetchPermissionsFailure(namespace: string, repoName?: string, error: Error): Action {
return {
type: FETCH_PERMISSIONS_FAILURE,
payload: {
namespace,
repoName,
error
},
itemId: createPermissionStateKey(namespace, repoName)
};
}
// modify permission
export function modifyPermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyPermissionPending(permission, namespace, repoName));
return apiClient
.put(permission._links.update.href, permission, CONTENT_TYPE)
.then(() => {
dispatch(modifyPermissionSuccess(permission, namespace, repoName));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyPermissionFailure(permission, err, namespace, repoName));
});
};
}
export function modifyPermissionPending(permission: Permission, namespace: string, repoName?: string): Action {
return {
type: MODIFY_PERMISSION_PENDING,
payload: permission,
itemId: createItemId(permission, namespace, repoName)
};
}
export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action {
return {
type: MODIFY_PERMISSION_SUCCESS,
payload: {
permission,
position: createPermissionStateKey(namespace, repoName)
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function modifyPermissionFailure(
permission: Permission,
error: Error,
namespace: string,
repoName?: string
): Action {
return {
type: MODIFY_PERMISSION_FAILURE,
payload: {
error,
permission
},
itemId: createItemId(permission, namespace, repoName)
};
}
function newPermissions(oldPermissions: PermissionCollection, newPermission: Permission) {
for (let i = 0; i < oldPermissions.length; i++) {
if (oldPermissions[i].name === newPermission.name) {
oldPermissions.splice(i, 1, newPermission);
return oldPermissions;
}
}
}
export function modifyPermissionReset(namespace: string, repoName?: string) {
return {
type: MODIFY_PERMISSION_RESET,
payload: {
namespace,
repoName
},
itemId: createPermissionStateKey(namespace, repoName)
};
}
// create permission
export function createPermission(
link: string,
permission: PermissionCreateEntry,
namespace: string,
repoName?: string,
callback?: () => void
) {
return function(dispatch: Dispatch) {
dispatch(createPermissionPending(permission, namespace, repoName));
return apiClient
.post(link, permission, CONTENT_TYPE)
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location);
})
.then(response => response.json())
.then(createdPermission => {
dispatch(createPermissionSuccess(createdPermission, namespace, repoName));
if (callback) {
callback();
}
})
.catch(err => dispatch(createPermissionFailure(err, namespace, repoName)));
};
}
export function createPermissionPending(
permission: PermissionCreateEntry,
namespace: string,
repoName?: string
): Action {
return {
type: CREATE_PERMISSION_PENDING,
payload: permission,
itemId: createPermissionStateKey(namespace, repoName)
};
}
export function createPermissionSuccess(
permission: PermissionCreateEntry,
namespace: string,
repoName?: string
): Action {
return {
type: CREATE_PERMISSION_SUCCESS,
payload: {
permission,
position: createPermissionStateKey(namespace, repoName)
},
itemId: createPermissionStateKey(namespace, repoName)
};
}
export function createPermissionFailure(error: Error, namespace: string, repoName?: string): Action {
return {
type: CREATE_PERMISSION_FAILURE,
payload: error,
itemId: createPermissionStateKey(namespace, repoName)
};
}
export function createPermissionReset(namespace: string, repoName?: string) {
return {
type: CREATE_PERMISSION_RESET,
itemId: createPermissionStateKey(namespace, repoName)
};
}
// delete permission
export function deletePermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) {
return function(dispatch: any) {
dispatch(deletePermissionPending(permission, namespace, repoName));
return apiClient
.delete(permission._links.delete.href)
.then(() => {
dispatch(deletePermissionSuccess(permission, namespace, repoName));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(deletePermissionFailure(permission, namespace, repoName, err));
});
};
}
export function deletePermissionPending(permission: Permission, namespace: string, repoName?: string): Action {
return {
type: DELETE_PERMISSION_PENDING,
payload: permission,
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action {
return {
type: DELETE_PERMISSION_SUCCESS,
payload: {
permission,
position: createPermissionStateKey(namespace, repoName)
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionFailure(
permission: Permission,
namespace: string,
repoName?: string,
error: Error
): Action {
return {
type: DELETE_PERMISSION_FAILURE,
payload: {
error,
permission
},
itemId: createItemId(permission, namespace, repoName)
};
}
export function deletePermissionReset(namespace: string, repoName?: string) {
return {
type: DELETE_PERMISSION_RESET,
payload: {
namespace,
repoName
},
itemId: createPermissionStateKey(namespace, repoName)
};
}
function deletePermissionFromState(oldPermissions: PermissionCollection, permission: Permission) {
const newPermission = [];
for (let i = 0; i < oldPermissions.length; i++) {
if (
oldPermissions[i].name !== permission.name ||
oldPermissions[i].groupPermission !== permission.groupPermission
) {
newPermission.push(oldPermissions[i]);
}
}
return newPermission;
}
function createItemId(permission: Permission, namespace: string, repoName?: string) {
const groupPermission = permission.groupPermission ? "@" : "";
return createPermissionStateKey(namespace, repoName) + "/" + groupPermission + permission.name;
}
// reducer
export default function reducer(
state: object = {},
action: Action = {
type: "UNKNOWN"
}
): object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_AVAILABLE_SUCCESS:
return {
...state,
available: action.payload
};
case FETCH_PERMISSIONS_SUCCESS:
return {
...state,
[action.itemId]: {
entries: action.payload._embedded.permissions,
createPermission: !!action.payload._links.create
}
};
case MODIFY_PERMISSION_SUCCESS: {
const positionOfPermission = action.payload.position;
const newPermission = newPermissions(state[action.payload.position].entries, action.payload.permission);
return {
...state,
[positionOfPermission]: {
...state[positionOfPermission],
entries: newPermission
}
};
}
case CREATE_PERMISSION_SUCCESS: {
// return state;
const position = action.payload.position;
const permissions = state[action.payload.position].entries;
permissions.push(action.payload.permission);
return {
...state,
[position]: {
...state[position],
entries: permissions
}
};
}
case DELETE_PERMISSION_SUCCESS: {
const permissionPosition = action.payload.position;
const newPermissions = deletePermissionFromState(
state[action.payload.position].entries,
action.payload.permission
);
return {
...state,
[permissionPosition]: {
...state[permissionPosition],
entries: newPermissions
}
};
}
default:
return state;
}
}
// selectors
export function getAvailablePermissions(state: object) {
if (state.permissions) {
return state.permissions.available;
}
}
export function getAvailableRepositoryRoles(state: object) {
return available(state).repositoryRoles;
}
export function getAvailableRepositoryVerbs(state: object) {
return available(state).repositoryVerbs;
}
function available(state: object) {
if (state.permissions && state.permissions.available) {
return state.permissions.available;
}
return {};
}
export function getPermissionsOfRepo(state: object, namespace: string, repoName?: string) {
if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) {
return state.permissions[createPermissionStateKey(namespace, repoName)].entries;
}
}
export function isFetchAvailablePermissionsPending(state: object) {
return isPending(state, FETCH_AVAILABLE, "available");
}
export function isFetchPermissionsPending(state: object, namespace: string, repoName?: string) {
return isPending(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName));
}
export function getFetchAvailablePermissionsFailure(state: object) {
return getFailure(state, FETCH_AVAILABLE, "available");
}
export function getFetchPermissionsFailure(state: object, namespace: string, repoName?: string) {
return getFailure(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName));
}
export function isModifyPermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) {
return isPending(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName)));
}
export function getModifyPermissionFailure(
state: object,
namespace: string,
repoName?: string,
permission: Permission
) {
return getFailure(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName)));
}
export function hasCreatePermission(state: object, namespace: string, repoName?: string) {
if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)])
return state.permissions[createPermissionStateKey(namespace, repoName)].createPermission;
else return null;
}
export function isCreatePermissionPending(state: object, namespace: string, repoName?: string) {
return isPending(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName));
}
export function getCreatePermissionFailure(state: object, namespace: string, repoName?: string) {
return getFailure(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName));
}
export function isDeletePermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) {
return isPending(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName));
}
export function getDeletePermissionFailure(
state: object,
namespace: string,
repoName?: string,
permission: Permission
) {
return getFailure(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName));
}
export function getDeletePermissionsFailure(state: object, namespace: string, repoName?: string) {
const permissions =
state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]
? state.permissions[createPermissionStateKey(namespace, repoName)].entries
: null;
if (permissions == null) return undefined;
for (let i = 0; i < permissions.length; i++) {
if (getDeletePermissionFailure(state, namespace, repoName, permissions[i])) {
return getFailure(state, DELETE_PERMISSION, createItemId(permissions[i], namespace, repoName));
}
}
return null;
}
export function getModifyPermissionsFailure(state: object, namespace: string, repoName?: string) {
const permissions =
state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]
? state.permissions[createPermissionStateKey(namespace, repoName)].entries
: null;
if (permissions == null) return undefined;
for (let i = 0; i < permissions.length; i++) {
if (getModifyPermissionFailure(state, namespace, repoName, permissions[i])) {
return getFailure(state, MODIFY_PERMISSION, createItemId(permissions[i], namespace, repoName));
}
}
return null;
}
function createPermissionStateKey(namespace: string, repoName?: string) {
return namespace + (repoName ? "/" + repoName : "");
}
export function findVerbsForRole(availableRepositoryRoles: RepositoryRole[], roleName: string) {
const matchingRole = availableRepositoryRoles.find(role => roleName === role.name);
if (matchingRole) {
return matchingRole.verbs;
} else {
return [];
}
}

View File

@@ -0,0 +1,37 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
import { RepositoryRole } from "@scm-manager/ui-types";
const findVerbsForRole = (availableRepositoryRoles: RepositoryRole[], roleName: string) => {
const matchingRole = availableRepositoryRoles.find(role => roleName === role.name);
if (matchingRole) {
return matchingRole.verbs;
} else {
return [];
}
};
export default findVerbsForRole;

View File

@@ -20,22 +20,25 @@
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
import { validation } from "@scm-manager/ui-components";
import { PermissionCollection } from "@scm-manager/ui-types";
import { Permission } from "@scm-manager/ui-types";
const isNameValid = validation.isNameValid;
export { isNameValid };
export const isPermissionValid = (name: string, groupPermission: boolean, permissions: PermissionCollection) => {
export const isPermissionValid = (name: string, groupPermission: boolean, permissions: Permission[]) => {
return isNameValid(name) && !currentPermissionIncludeName(name, groupPermission, permissions);
};
const currentPermissionIncludeName = (name: string, groupPermission: boolean, permissions: PermissionCollection) => {
const currentPermissionIncludeName = (name: string, groupPermission: boolean, permissions: Permission[]) => {
for (let i = 0; i < permissions.length; i++) {
if (permissions[i].name === name && permissions[i].groupPermission === groupPermission) return true;
if (permissions[i].name === name && permissions[i].groupPermission === groupPermission) {
return true;
}
}
return false;
};

View File

@@ -21,49 +21,22 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { compose } from "redux";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { binder } from "@scm-manager/ui-extensions";
import { File, Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Notification } from "@scm-manager/ui-components";
import {
fetchSources,
getFetchSourcesFailure,
getHunkCount,
getSources,
isFetchSourcesPending
} from "../modules/sources";
import { File } from "@scm-manager/ui-types";
import { Notification } from "@scm-manager/ui-components";
import FileTreeLeaf from "./FileTreeLeaf";
import { Button } from "@scm-manager/ui-components";
import TruncatedNotification from "./TruncatedNotification";
type Hunk = {
tree: File;
loading: boolean;
error: Error;
};
type Props = WithTranslation & {
repository: Repository;
revision: string;
path: string;
type Props = {
directory: File;
baseUrl: string;
location: any;
hunks: Hunk[];
// dispatch props
fetchSources: (repository: Repository, revision: string, path: string, hunk: number) => void;
updateSources: (hunk: number) => () => void;
// context props
match: any;
};
type State = {
stoppableUpdateHandler: number[];
revision: string;
fetchNextPage: () => void;
isFetchingNextPage: boolean;
};
const FixedWidthTh = styled.th`
@@ -82,177 +55,58 @@ export function findParent(path: string) {
return "";
}
class FileTree extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { stoppableUpdateHandler: [] };
}
const FileTree: FC<Props> = ({ directory, baseUrl, revision, fetchNextPage, isFetchingNextPage }) => {
const [t] = useTranslation("repos");
const { path } = directory;
const files: File[] = [];
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
if (prevState.stoppableUpdateHandler === this.state.stoppableUpdateHandler) {
const { hunks, updateSources } = this.props;
hunks?.forEach((hunk, index) => {
if (hunk.tree?._embedded?.children && hunk.tree._embedded.children.find(c => c.partialResult)) {
const stoppableUpdateHandler = setTimeout(updateSources(index), 3000);
this.setState(prevState => {
return {
stoppableUpdateHandler: [...prevState.stoppableUpdateHandler, stoppableUpdateHandler]
};
});
}
});
}
}
componentWillUnmount(): void {
this.state.stoppableUpdateHandler.forEach(handler => clearTimeout(handler));
}
loadMore = () => {
this.props.fetchSources(
this.props.repository,
decodeURIComponent(this.props.revision),
this.props.path,
this.props.hunks.length
);
};
renderTruncatedInfo = () => {
const { hunks, t } = this.props;
const lastHunk = hunks[hunks.length - 1];
const fileCount = hunks
.filter(hunk => hunk?.tree?._embedded?.children)
.map(hunk => hunk.tree._embedded.children.filter(c => !c.directory).length)
.reduce((a, b) => a + b, 0);
if (lastHunk.tree?.truncated) {
return (
<Notification type={"info"}>
<div className={"columns is-centered"}>
<div className={"column"}>{t("sources.moreFilesAvailable", { count: fileCount })}</div>
<Button label={t("sources.loadMore")} action={this.loadMore} />
</div>
</Notification>
);
}
};
render() {
const { hunks } = this.props;
if (!hunks || hunks.length === 0) {
return null;
}
if (hunks[0]?.error) {
return <ErrorNotification error={hunks[0].error} />;
}
return (
<div className="panel-block">
{this.renderSourcesTable()}
{this.renderTruncatedInfo()}
</div>
);
}
renderSourcesTable() {
const { hunks, revision, path, baseUrl, t } = this.props;
const files = [];
if (path) {
files.push({
name: "..",
path: findParent(path),
directory: true
});
}
if (hunks.every(hunk => hunk.loading)) {
return <Loading />;
}
hunks
.filter(hunk => !hunk.loading)
.forEach(hunk => {
if (hunk.tree?._embedded && hunk.tree._embedded.children) {
const children = [...hunk.tree._embedded.children];
files.push(...children);
}
});
const loading = hunks.filter(hunk => hunk.loading).length > 0;
if (loading || (files && files.length > 0)) {
let baseUrlWithRevision = baseUrl;
if (revision) {
baseUrlWithRevision += "/" + encodeURIComponent(revision);
} else {
baseUrlWithRevision += "/" + encodeURIComponent(hunks[0].tree.revision);
if (path) {
files.push({
name: "..",
path: findParent(path),
directory: true,
revision,
_links: {},
_embedded: {
children: []
}
return (
<>
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<FixedWidthTh />
<th>{t("sources.fileTree.name")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.length")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.fileTree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr>
</thead>
<tbody>
{files.map((file: any) => (
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} />
))}
</tbody>
</table>
{hunks[hunks.length - 1].loading && <Loading />}
{hunks[hunks.length - 1].error && <ErrorNotification error={hunks[hunks.length - 1].error} />}
</>
);
}
return <Notification type="info">{t("sources.noSources")}</Notification>;
}
}
const mapDispatchToProps = (dispatch: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
return {
updateSources: (hunk: number) => () => dispatch(fetchSources(repository, revision, path, false, hunk)),
fetchSources: (repository: Repository, revision: string, path: string, hunk: number) => {
dispatch(fetchSources(repository, revision, path, true, hunk));
}
};
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
const error = getFetchSourcesFailure(state, repository, revision, path, 0);
const hunkCount = getHunkCount(state, repository, revision, path);
const hunks = [];
for (let i = 0; i < hunkCount; ++i) {
const tree = getSources(state, repository, revision, path, i);
const loading = isFetchSourcesPending(state, repository, revision, path, i);
const error = getFetchSourcesFailure(state, repository, revision, path, i);
hunks.push({
tree,
loading,
error
});
}
return {
revision,
path,
error,
hunks
};
files.push(...(directory._embedded.children || []));
const baseUrlWithRevision = baseUrl + "/" + encodeURIComponent(revision);
if (!files || files.length === 0) {
return <Notification type="info">{t("sources.noSources")}</Notification>;
}
return (
<div className="panel-block">
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<FixedWidthTh />
<th>{t("sources.fileTree.name")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.length")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.fileTree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr>
</thead>
<tbody>
{files.map((file: File) => (
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} />
))}
</tbody>
</table>
<TruncatedNotification
directory={directory}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</div>
);
};
export default compose(withRouter, connect(mapStateToProps, mapDispatchToProps))(withTranslation("repos")(FileTree));
export default FileTree;

View File

@@ -0,0 +1,55 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
import React, { FC } from "react";
import { File } from "@scm-manager/ui-types";
import { Button, Notification } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
directory: File;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
const TruncatedNotification: FC<Props> = ({ directory, isFetchingNextPage, fetchNextPage }) => {
const [t] = useTranslation("repos");
if (!directory.truncated) {
return null;
}
const fileCount = (directory._embedded.children || []).filter(file => !file.directory).length;
return (
<Notification type="info">
<div className="columns is-centered">
<div className="column">{t("sources.moreFilesAvailable", { count: fileCount })}</div>
<Button label={t("sources.loadMore")} action={fetchNextPage} loading={isFetchingNextPage} />
</div>
</Notification>
);
};
export default TruncatedNotification;

View File

@@ -22,21 +22,18 @@
* SOFTWARE.
*/
import React, { ReactNode } from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { File, Repository } from "@scm-manager/ui-types";
import { DateFromNow, ErrorNotification, FileSize, Icon, OpenInFullscreenButton } from "@scm-manager/ui-components";
import { getSources } from "../modules/sources";
import FileButtonAddons from "../components/content/FileButtonAddons";
import SourcesView from "./SourcesView";
import HistoryView from "./HistoryView";
import AnnotateView from "./AnnotateView";
type Props = WithTranslation & {
loading: boolean;
file: File;
repository: Repository;
revision: string;
@@ -181,7 +178,7 @@ class Content extends React.Component<Props, State> {
</tr>
<tr>
<td>{t("sources.content.branch")}</td>
<td className="is-word-break">{decodeURIComponent(revision)}</td>
<td className="is-word-break">{revision}</td>
</tr>
<tr>
<td>{t("sources.content.size")}</td>
@@ -246,14 +243,4 @@ class Content extends React.Component<Props, State> {
}
}
const mapStateToProps = (state: any, ownProps: Props) => {
const { repository, revision, path } = ownProps;
const file = getSources(state, repository, revision, path);
return {
file
};
};
export default connect(mapStateToProps)(withTranslation("repos")(Content));
export default withTranslation("repos")(Content);

View File

@@ -21,91 +21,55 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { File, Repository } from "@scm-manager/ui-types";
import { RouteComponentProps, withRouter } from "react-router-dom";
import React, { FC } from "react";
import { Repository } from "@scm-manager/ui-types";
import { useParams } from "react-router-dom";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { fetchSources, getFetchSourcesFailure, getSources, isFetchSourcesPending } from "../modules/sources";
import { connect } from "react-redux";
import { ErrorNotification, Loading, Notification } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
type Props = WithTranslation &
RouteComponentProps & {
repository: Repository;
baseUrl: string;
// url params
extension: string;
revision?: string;
path?: string;
// redux state
loading: boolean;
error?: Error | null;
sources?: File | null;
// dispatch props
fetchSources: (repository: Repository, revision?: string, path?: string) => void;
};
import { useTranslation } from "react-i18next";
import { useSources } from "@scm-manager/ui-api";
const extensionPointName = "repos.sources.extensions";
class SourceExtensions extends React.Component<Props> {
componentDidMount() {
const { fetchSources, repository, revision, path } = this.props;
// TODO get typing right
fetchSources(repository, revision, path);
}
type Props = {
repository: Repository;
baseUrl: string;
};
render() {
const { loading, error, repository, extension, revision, path, sources, baseUrl, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
const extprops = { extension, repository, revision, path, sources, baseUrl };
if (!binder.hasExtension(extensionPointName, extprops)) {
return <Notification type="warning">{t("sources.extension.notBound")}</Notification>;
}
return <ExtensionPoint name={extensionPointName} props={extprops} />;
}
}
const mapStateToProps = (state: any, ownProps: Props): Partial<Props> => {
const { repository, match } = ownProps;
// @ts-ignore
const revision: string = match.params.revision;
// @ts-ignore
const path: string = match.params.path;
// @ts-ignore
const extension: string = match.params.extension;
const loading = isFetchSourcesPending(state, repository, revision, path);
const error = getFetchSourcesFailure(state, repository, revision, path);
const sources = getSources(state, repository, revision, path);
type Params = {
revision: string;
path: string;
extension: string;
};
const useUrlParams = () => {
const { revision, path, extension } = useParams<Params>();
return {
repository,
extension,
revision,
path,
loading,
error,
sources
revision: revision ? decodeURIComponent(revision) : undefined,
path: path,
extension
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchSources: (repository: Repository, revision: string, path: string) => {
dispatch(fetchSources(repository, decodeURIComponent(revision), path));
}
};
const SourceExtensions: FC<Props> = ({ repository, baseUrl }) => {
const { revision, path, extension } = useUrlParams();
const { error, isLoading, data: sources } = useSources(repository, { revision, path });
const [t] = useTranslation("repos");
if (error) {
return <ErrorNotification error={error} />;
}
if (isLoading) {
return <Loading />;
}
const extprops = { extension, repository, revision, path, sources, baseUrl };
if (!binder.hasExtension(extensionPointName, extprops)) {
return <Notification type="warning">{t("sources.extension.notBound")}</Notification>;
}
return <ExtensionPoint name={extensionPointName} props={extprops} />;
};
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(withTranslation("repos")(SourceExtensions)));
export default SourceExtensions;

View File

@@ -21,61 +21,64 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { connect } from "react-redux";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useEffect } from "react";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Breadcrumb, ErrorNotification, Loading } from "@scm-manager/ui-components";
import FileTree from "../components/FileTree";
import { getFetchBranchesFailure, isFetchBranchesPending } from "../../branches/modules/branches";
import { compose } from "redux";
import Content from "./Content";
import { fetchSources, getSources, isDirectory } from "../modules/sources";
import CodeActionBar from "../../codeSection/components/CodeActionBar";
import replaceBranchWithRevision from "../ReplaceBranchWithRevision";
import { useSources } from "@scm-manager/ui-api";
import { useHistory, useLocation, useParams } from "react-router-dom";
type Props = WithTranslation &
RouteComponentProps & {
repository: Repository;
loading: boolean;
error: Error;
baseUrl: string;
branches: Branch[];
revision: string;
path: string;
currentFileIsDirectory: boolean;
sources: File;
fileRevision: string;
selectedBranch: string;
type Props = {
repository: Repository;
branches?: Branch[];
selectedBranch?: string;
baseUrl: string;
};
// dispatch props
fetchSources: (repository: Repository, revision: string, path: string) => void;
type Params = {
revision?: string;
path: string;
};
const useUrlParams = () => {
const { revision, path } = useParams<Params>();
return {
revision: revision ? decodeURIComponent(revision) : undefined,
path: path || ""
};
};
class Sources extends React.Component<Props> {
componentDidMount() {
const { repository, branches, selectedBranch, baseUrl, revision, path, fetchSources } = this.props;
fetchSources(repository, this.decodeRevision(revision), path);
if (branches?.length > 0 && !selectedBranch) {
const defaultBranch = branches?.filter((b) => b.defaultBranch === true)[0];
this.props.history.replace(`${baseUrl}/sources/${encodeURIComponent(defaultBranch.name)}/`);
const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) => {
const { revision, path } = useUrlParams();
const history = useHistory();
const location = useLocation();
// redirect to default branch is non branch selected
useEffect(() => {
if (branches && branches.length > 0 && !selectedBranch) {
const defaultBranch = branches?.filter(b => b.defaultBranch === true)[0];
history.replace(`${baseUrl}/sources/${encodeURIComponent(defaultBranch.name)}/`);
}
}, [branches, selectedBranch]);
const { isLoading, error, data: file, isFetchingNextPage, fetchNextPage } = useSources(repository, {
revision,
path,
// we have to wait until a branch is selected,
// expect if we have no branches (svn)
enabled: !branches || !!selectedBranch
});
if (error) {
return <ErrorNotification error={error} />;
}
componentDidUpdate(prevProps: Props) {
const { fetchSources, repository, revision, path } = this.props;
if (prevProps.revision !== revision || prevProps.path !== path) {
fetchSources(repository, this.decodeRevision(revision), path);
}
if (isLoading || !file) {
return <Loading />;
}
decodeRevision = (revision: string) => {
return revision ? decodeURIComponent(revision) : revision;
};
onSelectBranch = (branch?: Branch) => {
const { baseUrl, history, path } = this.props;
const onSelectBranch = (branch?: Branch) => {
let url;
if (branch) {
if (path) {
@@ -90,125 +93,70 @@ class Sources extends React.Component<Props> {
history.push(url);
};
evaluateSwitchViewLink = () => {
const { baseUrl, selectedBranch, branches } = this.props;
if (branches && selectedBranch && branches?.filter((b) => b.name === selectedBranch).length !== 0) {
const evaluateSwitchViewLink = () => {
if (branches && selectedBranch && branches?.filter(b => b.name === selectedBranch).length !== 0) {
return `${baseUrl}/branch/${encodeURIComponent(selectedBranch)}/changesets/`;
}
return `${baseUrl}/changesets/`;
};
render() {
const {
repository,
baseUrl,
branches,
selectedBranch,
loading,
error,
revision,
path,
currentFileIsDirectory
} = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (currentFileIsDirectory) {
return (
<>
<CodeActionBar
selectedBranch={selectedBranch}
branches={branches}
onSelectBranch={this.onSelectBranch}
switchViewLink={this.evaluateSwitchViewLink()}
/>
<div className="panel">
{this.renderBreadcrumb()}
<FileTree repository={repository} revision={revision} path={path} baseUrl={baseUrl + "/sources"} />
</div>
</>
);
} else {
return (
<>
<CodeActionBar
selectedBranch={selectedBranch}
branches={branches}
onSelectBranch={this.onSelectBranch}
switchViewLink={this.evaluateSwitchViewLink()}
/>
<Content repository={repository} revision={revision} path={path} breadcrumb={this.renderBreadcrumb()} />
</>
);
}
}
renderBreadcrumb = () => {
const {
revision,
selectedBranch,
path,
baseUrl,
branches,
sources,
repository,
location,
fileRevision
} = this.props;
const permalink = fileRevision ? replaceBranchWithRevision(location.pathname, fileRevision) : null;
const renderBreadcrumb = () => {
const permalink = file?.revision ? replaceBranchWithRevision(location.pathname, file.revision) : null;
return (
<Breadcrumb
repository={repository}
revision={revision}
path={path}
revision={revision || file.revision}
path={path || ""}
baseUrl={baseUrl + "/sources"}
branch={branches?.filter((b) => b.name === selectedBranch)[0]}
defaultBranch={branches?.filter((b) => b.defaultBranch === true)[0]}
sources={sources}
branch={branches?.filter(b => b.name === selectedBranch)[0]}
defaultBranch={branches?.filter(b => b.defaultBranch === true)[0]}
sources={file}
permalink={permalink}
/>
);
};
}
const mapStateToProps = (state: any, ownProps: Props) => {
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 currentFileIsDirectory = decodedRevision
? isDirectory(state, repository, decodedRevision, path)
: isDirectory(state, repository, revision, path);
const sources = getSources(state, repository, decodedRevision, path);
const file = getSources(state, repository, revision, path);
const fileRevision = file?.revision;
return {
repository,
revision,
path,
loading,
error,
currentFileIsDirectory,
sources,
fileRevision
};
if (file.directory) {
return (
<>
<CodeActionBar
selectedBranch={selectedBranch}
branches={branches}
onSelectBranch={onSelectBranch}
switchViewLink={evaluateSwitchViewLink()}
/>
<div className="panel">
{renderBreadcrumb()}
<FileTree
directory={file}
revision={revision || file.revision}
baseUrl={baseUrl + "/sources"}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
</div>
</>
);
} else {
return (
<>
<CodeActionBar
selectedBranch={selectedBranch}
branches={branches}
onSelectBranch={onSelectBranch}
switchViewLink={evaluateSwitchViewLink()}
/>
<Content
file={file}
repository={repository}
revision={revision || file.revision}
path={path}
breadcrumb={renderBreadcrumb()}
/>
</>
);
}
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchSources: (repository: Repository, revision: string, path: string) => {
dispatch(fetchSources(repository, revision, path));
}
};
};
export default compose(withTranslation("repos"), withRouter, connect(mapStateToProps, mapDispatchToProps))(Sources);
export default Sources;

View File

@@ -1,336 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { File, Repository } from "@scm-manager/ui-types";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import {
default as reducer,
FETCH_SOURCES,
FETCH_SOURCES_FAILURE,
FETCH_SOURCES_PENDING,
FETCH_SOURCES_SUCCESS,
fetchSources,
fetchSourcesSuccess,
getFetchSourcesFailure,
getSources,
isDirectory,
isFetchSourcesPending
} 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 = {
name: "src",
path: "src",
directory: true,
description: "foo",
length: 176,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
subRepository: undefined,
truncated: true,
partialResult: false,
computationAborted: false,
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/"
}
},
_embedded: {
children: [
{
name: "src",
path: "src",
directory: true,
length: 176,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
subRepository: undefined,
_links: {
self: {
href:
"http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src"
}
},
_embedded: {
children: []
}
},
{
name: "package.json",
path: "package.json",
directory: false,
description: "bump version",
length: 780,
revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
commitDate: "2017-07-31T11:17:19Z",
subRepository: undefined,
_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"
}
},
_embedded: {
children: []
}
}
]
}
};
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: {
children: []
}
};
describe("sources fetch", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch the sources of the repository", () => {
fetchMock.getOnce(sourcesUrl + "?offset=0", collection);
const expectedActions = [
{
type: FETCH_SOURCES_PENDING,
itemId: "scm/core/_//",
payload: {
hunk: 0,
updatePending: false,
pending: true,
sources: {}
}
},
{
type: FETCH_SOURCES_SUCCESS,
itemId: "scm/core/_//",
payload: {
hunk: 0,
updatePending: false,
pending: false,
sources: collection
}
}
];
const store = mockStore({});
return store.dispatch(fetchSources(repository, "", "")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fetch the sources of the repository with the given revision and path", () => {
fetchMock.getOnce(sourcesUrl + "abc/src?offset=0", collection);
const expectedActions = [
{
type: FETCH_SOURCES_PENDING,
itemId: "scm/core/abc/src/",
payload: {
hunk: 0,
updatePending: false,
pending: true,
sources: {}
}
},
{
type: FETCH_SOURCES_SUCCESS,
itemId: "scm/core/abc/src/",
payload: {
hunk: 0,
updatePending: false,
pending: false,
sources: collection
}
}
];
const store = mockStore({});
return store.dispatch(fetchSources(repository, "abc", "src")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_SOURCES_FAILURE on server error", () => {
fetchMock.getOnce(sourcesUrl + "?offset=0", {
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, without revision and path", () => {
const expectedState = {
"scm/core/_//0": { pending: false, updatePending: false, sources: collection },
"scm/core/_//hunkCount": 1
};
expect(reducer({}, fetchSourcesSuccess(repository, "", "", 0, collection))).toEqual(expectedState);
});
it("should store the collection, with revision and path", () => {
const expectedState = {
"scm/core/abc/src/main/0": { pending: false, updatePending: false, sources: collection },
"scm/core/abc/src/main/hunkCount": 1
};
expect(reducer({}, fetchSourcesSuccess(repository, "abc", "src/main", 0, collection))).toEqual(expectedState);
});
});
describe("selector tests", () => {
it("should return false if it is no directory", () => {
const state = {
sources: {
"scm/core/abc/src/main/package.json/0": {
sources: { 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/0": noDirectory
}
};
expect(isDirectory(state, repository, "abc", "src")).toBe(true);
});
it("should return null", () => {
expect(getSources({}, repository, "", "")).toBeFalsy();
});
it("should return the source collection without revision and path", () => {
const state = {
sources: {
"scm/core/_//0": {
sources: collection
}
}
};
expect(getSources(state, repository, "", "")).toBe(collection);
});
it("should return the source collection with revision and path", () => {
const state = {
sources: {
"scm/core/abc/src/main/0": {
sources: collection
}
}
};
expect(getSources(state, repository, "abc", "src/main")).toBe(collection);
});
it("should return true, when fetch sources is pending", () => {
const state = {
sources: {
"scm/core/_//0": {
pending: true,
sources: {}
}
}
};
expect(isFetchSourcesPending(state, repository, "", "", 0)).toEqual(true);
});
it("should return false, when fetch sources is not pending", () => {
const state = {
sources: {
"scm/core/_//0": {
pending: false,
sources: {}
}
}
};
expect(isFetchSourcesPending(state, repository, "", "", 0)).toEqual(false);
});
const error = new Error("incredible error from hell");
it("should return error when fetch sources did fail", () => {
const state = {
sources: {
"scm/core/_//0": {
pending: false,
sources: {},
error: error
}
}
};
expect(getFetchSourcesFailure(state, repository, "", "", 0)).toEqual(error);
});
it("should return undefined when fetch sources did not fail", () => {
expect(getFetchSourcesFailure({}, repository, "", "", 0)).toBe(null);
});
});

View File

@@ -1,281 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as types from "../../../modules/types";
import { Action, File, Link, Repository } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
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_UPDATES_PENDING = `${FETCH_SOURCES}_UPDATE_PENDING`;
export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_UPDATES_SUCCESS = `${FETCH_SOURCES}_UPDATE_SUCCESS`;
export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`;
export function fetchSources(repository: Repository, revision: string, path: string, initialLoad = true, hunk = 0) {
return function(dispatch: any, getState: () => any) {
const state = getState();
if (
isFetchSourcesPending(state, repository, revision, path, hunk) ||
isUpdateSourcePending(state, repository, revision, path, hunk)
) {
return;
}
if (initialLoad) {
dispatch(fetchSourcesPending(repository, revision, path, hunk));
} else {
dispatch(
updateSourcesPending(repository, revision, path, hunk, getSources(state, repository, revision, path, hunk))
);
}
let offset = 0;
for (let i = 0; i < hunk; ++i) {
const sources = getSources(state, repository, revision, path, i);
if (sources?._embedded.children) {
offset += sources._embedded.children.filter(c => !c.directory).length;
}
}
return apiClient
.get(createUrl(repository, revision, path, offset))
.then(response => response.json())
.then((sources: File) => {
if (initialLoad) {
dispatch(fetchSourcesSuccess(repository, revision, path, hunk, sources));
} else {
dispatch(fetchUpdatesSuccess(repository, revision, path, hunk, sources));
}
})
.catch(err => {
dispatch(fetchSourcesFailure(repository, revision, path, hunk, err));
});
};
}
function createUrl(repository: Repository, revision: string, path: string, offset: number) {
const base = (repository._links.sources as Link).href;
if (!revision && !path) {
return `${base}?offset=${offset}`;
}
// TODO handle trailing slash
const pathDefined = path ? path : "";
return `${base}${encodeURIComponent(revision)}/${pathDefined}?offset=${offset}`;
}
export function fetchSourcesPending(repository: Repository, revision: string, path: string, hunk: number): Action {
return {
type: FETCH_SOURCES_PENDING,
itemId: createItemId(repository, revision, path, ""),
payload: { hunk, pending: true, updatePending: false, sources: {} }
};
}
export function updateSourcesPending(
repository: Repository,
revision: string,
path: string,
hunk: number,
currentSources: File
): Action {
return {
type: FETCH_UPDATES_PENDING,
payload: { hunk, pending: false, updatePending: true, sources: currentSources },
itemId: createItemId(repository, revision, path, "")
};
}
export function fetchSourcesSuccess(
repository: Repository,
revision: string,
path: string,
hunk: number,
sources: File
) {
return {
type: FETCH_SOURCES_SUCCESS,
payload: { hunk, pending: false, updatePending: false, sources },
itemId: createItemId(repository, revision, path, "")
};
}
export function fetchUpdatesSuccess(
repository: Repository,
revision: string,
path: string,
hunk: number,
sources: File
) {
return {
type: FETCH_UPDATES_SUCCESS,
payload: { hunk, pending: false, updatePending: false, sources },
itemId: createItemId(repository, revision, path, "")
};
}
export function fetchSourcesFailure(
repository: Repository,
revision: string,
path: string,
hunk: number,
error: Error
): Action {
return {
type: FETCH_SOURCES_FAILURE,
payload: { hunk, pending: false, updatePending: false, error },
itemId: createItemId(repository, revision, path, "")
};
}
function createItemId(repository: Repository, revision: string | undefined, path: string, hunk: number | string) {
const revPart = revision ? revision : "_";
const pathPart = path ? path : "";
return `${repository.namespace}/${repository.name}/${decodeURIComponent(revPart)}/${pathPart}/${hunk}`;
}
// reducer
export default function reducer(
state: any = {},
action: Action = {
type: "UNKNOWN"
}
): any {
if (action.itemId && (action.type === FETCH_SOURCES_SUCCESS || action.type === FETCH_SOURCES_FAILURE)) {
return {
...state,
[action.itemId + "hunkCount"]: action.payload.hunk + 1,
[action.itemId + action.payload.hunk]: {
sources: action.payload.sources,
error: action.payload.error,
updatePending: false,
pending: false
}
};
} else if (action.itemId && action.type === FETCH_UPDATES_SUCCESS) {
return {
...state,
[action.itemId + action.payload.hunk]: {
sources: action.payload.sources,
error: action.payload.error,
updatePending: false,
pending: false
}
};
} else if (action.itemId && action.type === FETCH_UPDATES_PENDING) {
return {
...state,
[action.itemId + action.payload.hunk]: {
sources: action.payload.sources,
updatePending: true,
pending: false
}
};
} else if (action.itemId && action.type === FETCH_SOURCES_PENDING) {
return {
...state,
[action.itemId + "hunkCount"]: action.payload.hunk + 1,
[action.itemId + action.payload.hunk]: {
updatePending: false,
pending: true
}
};
} else {
return state;
}
}
// selectors
export function isDirectory(state: any, repository: Repository, revision: string, path: string): boolean {
const currentFile = getSources(state, repository, revision, path, 0);
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 getHunkCount(state: any, repository: Repository, revision: string | undefined, path: string): number {
if (state.sources) {
const count = state.sources[createItemId(repository, revision, path, "hunkCount")];
return count ? count : 0;
}
return 0;
}
export function getSources(
state: any,
repository: Repository,
revision: string | undefined,
path: string,
hunk = 0
): File | null | undefined {
if (state.sources) {
return state.sources[createItemId(repository, revision, path, hunk)]?.sources;
}
return null;
}
export function isFetchSourcesPending(
state: any,
repository: Repository,
revision: string,
path: string,
hunk = 0
): boolean {
if (state.sources) {
return state.sources[createItemId(repository, revision, path, hunk)]?.pending;
}
return false;
}
export function isUpdateSourcePending(
state: any,
repository: Repository,
revision: string,
path: string,
hunk: number
): boolean {
if (state.sources) {
return state.sources[createItemId(repository, revision, path, hunk)]?.updatePending;
}
return false;
}
export function getFetchSourcesFailure(
state: any,
repository: Repository,
revision: string,
path: string,
hunk = 0
): Error | null | undefined {
if (state.sources) {
return state.sources && state.sources[createItemId(repository, revision, path, hunk)]?.error;
}
return null;
}

View File

@@ -22,23 +22,29 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { Link, Tag } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { Link, Repository, Tag } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import TagRow from "./TagRow";
import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeleteTag } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
baseUrl: string;
tags: Tag[];
fetchTags: () => void;
};
const TagTable: FC<Props> = ({ baseUrl, tags, fetchTags }) => {
const TagTable: FC<Props> = ({ repository, baseUrl, tags }) => {
const { isLoading, error, remove, isDeleted } = useDeleteTag(repository);
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [tagToBeDeleted, setTagToBeDeleted] = useState<Tag | undefined>();
useEffect(() => {
if (isDeleted) {
resetAndCloseDialog();
}
}, [isDeleted]);
const onDelete = (tag: Tag) => {
setTagToBeDeleted(tag);
@@ -46,57 +52,53 @@ const TagTable: FC<Props> = ({ baseUrl, tags, fetchTags }) => {
};
const abortDelete = () => {
resetAndCloseDialog();
};
const resetAndCloseDialog = () => {
setTagToBeDeleted(undefined);
setShowConfirmAlert(false);
};
const deleteTag = () => {
apiClient
.delete((tagToBeDeleted?._links.delete as Link).href)
.then(() => fetchTags())
.catch(setError);
};
const renderRow = () => {
let rowContent = null;
if (tags) {
rowContent = tags.map((tag, index) => {
return <TagRow key={index} baseUrl={baseUrl} tag={tag} onDelete={onDelete} />;
});
if (tagToBeDeleted) {
remove(tagToBeDeleted);
}
return rowContent;
};
const confirmAlert = (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tagToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
onClick: () => deleteTag()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
);
return (
<>
{showConfirmAlert && confirmAlert}
{error && <ErrorNotification error={error} />}
{showConfirmAlert ? (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tagToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
isLoading,
onClick: () => deleteTag()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
) : null}
{error ? <ErrorNotification error={error} /> : null}
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
<th>{t("tags.table.tags")}</th>
</tr>
</thead>
<tbody>{renderRow()}</tbody>
<tbody>
{tags.map(tag => (
<TagRow key={tag.name} baseUrl={baseUrl} tag={tag} onDelete={onDelete} />
))}
</tbody>
</table>
</>
);

View File

@@ -24,9 +24,10 @@
import React, { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { apiClient, ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { Link, Repository, Tag } from "@scm-manager/ui-types";
import { Redirect } from "react-router-dom";
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { Repository, Tag } from "@scm-manager/ui-types";
import { useDeleteTag } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
@@ -34,48 +35,36 @@ type Props = {
};
const DeleteTag: FC<Props> = ({ tag, repository }) => {
const { isLoading, error, remove, isDeleted } = useDeleteTag(repository);
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [t] = useTranslation("repos");
const history = useHistory();
const deleteBranch = () => {
apiClient
.delete((tag._links.delete as Link).href)
.then(() => history.push(`/repo/${repository.namespace}/${repository.name}/tags/`))
.catch(setError);
};
if (!tag._links.delete) {
return null;
}
let confirmAlert = null;
if (showConfirmAlert) {
confirmAlert = (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tag.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
);
if (isDeleted) {
return <Redirect to={`/repo/${repository.namespace}/${repository.name}/tags/`} />;
}
return (
<>
<ErrorNotification error={error} />
{showConfirmAlert && confirmAlert}
{showConfirmAlert ? (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tag.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
isLoading,
onClick: () => remove(tag)
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
) : null}
<Level
left={
<p>

View File

@@ -22,55 +22,31 @@
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Link, Repository, Tag } from "@scm-manager/ui-types";
import { Redirect, Switch, useLocation, useRouteMatch, Route } from "react-router-dom";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import React, { FC } from "react";
import { Repository } from "@scm-manager/ui-types";
import { Redirect, Switch, useRouteMatch, Route } from "react-router-dom";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import TagView from "../components/TagView";
import { urls } from "@scm-manager/ui-components";
import { useTag } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
baseUrl: string;
};
type Params = {
tag: string;
};
const TagRoot: FC<Props> = ({ repository, baseUrl }) => {
const match = useRouteMatch();
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [tag, setTag] = useState<Tag>();
useEffect(() => {
const link = (repository._links?.tags as Link)?.href;
if (link) {
apiClient
.get(link)
.then(r => r.json())
.then(r => setTags(r._embedded.tags))
.catch(setError);
}
}, [repository]);
useEffect(() => {
const tagName = decodeURIComponent(match?.params?.tag);
const link = tags?.length > 0 && (tags.find(tag => tag.name === tagName)?._links.self as Link).href;
if (link) {
apiClient
.get(link)
.then(r => r.json())
.then(setTag)
.then(() => setLoading(false))
.catch(setError);
}
}, [tags]);
const match = useRouteMatch<Params>();
const { isLoading, error, data: tag } = useTag(repository, match.params.tag);
if (error) {
return <ErrorNotification error={error} />;
}
if (loading || !tags) {
if (isLoading || !tag) {
return <Loading />;
}
@@ -79,9 +55,10 @@ const TagRoot: FC<Props> = ({ repository, baseUrl }) => {
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`} component={() => <TagView repository={repository} tag={tag} />} />
<Route path={`${url}/info`}>
<TagView repository={repository} tag={tag} />
</Route>
</Switch>
);
};
export default TagRoot;

View File

@@ -22,12 +22,13 @@
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Repository, Tag, Link } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Notification, Subtitle, apiClient } from "@scm-manager/ui-components";
import React, { FC } from "react";
import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import orderTags from "../orderTags";
import TagTable from "../components/TagTable";
import { useTags } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
@@ -35,48 +36,28 @@ type Props = {
};
const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useTags(repository);
const [t] = useTranslation("repos");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [tags, setTags] = useState<Tag[]>([]);
const fetchTags = () => {
const link = (repository._links?.tags as Link)?.href;
if (link) {
setLoading(true);
apiClient
.get(link)
.then(r => r.json())
.then(r => setTags(r._embedded.tags))
.then(() => setLoading(false))
.catch(setError);
}
};
useEffect(() => {
fetchTags();
}, [repository]);
const renderTagsTable = () => {
if (!loading && tags?.length > 0) {
orderTags(tags);
return <TagTable baseUrl={baseUrl} tags={tags} fetchTags={fetchTags} />;
}
return <Notification type="info">{t("tags.overview.noTags")}</Notification>;
};
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
if (isLoading) {
return <Loading />;
}
const tags = data?._embedded.tags || [];
orderTags(tags);
return (
<>
<Subtitle subtitle={t("tags.overview.title")} />
{renderTagsTable()}
{tags.length > 0 ? (
<TagTable repository={repository} baseUrl={baseUrl} tags={tags} />
) : (
<Notification type="info">{t("tags.overview.noTags")}</Notification>
)}
</>
);
};