Implemented editing of repos (in UI)

This commit is contained in:
Philipp Czora
2018-08-06 15:41:20 +02:00
parent ffbfdff52e
commit 9a4896b55d
9 changed files with 331 additions and 6 deletions

View File

@@ -26,8 +26,15 @@
"title": "Create Repository", "title": "Create Repository",
"subtitle": "Create a new repository" "subtitle": "Create a new repository"
}, },
"edit": {
"title": "Edit Repository",
"subtitle": "Edit an existing repository"
},
"repository-form": { "repository-form": {
"submit": "Speichern" "submit": "Save"
},
"edit-nav-link": {
"label": "Edit"
}, },
"delete-nav-action": { "delete-nav-action": {
"label": "Delete", "label": "Delete",

View File

@@ -42,9 +42,8 @@ class Textarea extends React.Component<Props> {
}} }}
placeholder={placeholder} placeholder={placeholder}
onChange={this.handleInput} onChange={this.handleInput}
> value={value}
{value} />
</textarea>
</div> </div>
</div> </div>
); );

View File

@@ -47,7 +47,6 @@ class Main extends React.Component<Props> {
authenticated={authenticated} authenticated={authenticated}
/> />
<ProtectedRoute <ProtectedRoute
exact
path="/repo/:namespace/:name" path="/repo/:namespace/:name"
component={RepositoryRoot} component={RepositoryRoot}
authenticated={authenticated} authenticated={authenticated}

View File

@@ -0,0 +1,22 @@
//@flow
import React from "react";
import { NavLink } from "../../components/navigation";
import { translate } from "react-i18next";
import type { Repository } from "../types/Repositories";
type Props = { editUrl: string, t: string => string, repository: Repository };
class EditNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.repository._links.update;
};
render() {
if (!this.isEditable()) {
return null;
}
const { editUrl, t } = this.props;
return <NavLink to={editUrl} label={t("edit-nav-link.label")} />;
}
}
export default translate("repos")(EditNavLink);

View File

@@ -0,0 +1,32 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import EditNavLink from "./EditNavLink";
jest.mock("../../components/modals/ConfirmAlert");
jest.mock("../../components/navigation/NavLink", () => () => <div>foo</div>);
describe("EditNavLink", () => {
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(<EditNavLink repository={repository} editUrl="" />);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
update: {
href: "/repositories"
}
}
};
const navLink = mount(<EditNavLink repository={repository} editUrl="" />);
expect(navLink.text()).toBe("foo");
});
});

View File

@@ -0,0 +1,80 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Page } from "../../components/layout";
import RepositoryForm from "../components/form";
import type { Repository } from "../types/Repositories";
import {
modifyRepo,
isModifyRepoPending,
getModifyRepoFailure
} from "../modules/repos";
import { withRouter } from "react-router-dom";
import type { History } from "history";
type Props = {
repository: Repository,
modifyRepo: (Repository, () => void) => void,
modifyLoading: boolean,
error: Error,
// context props
t: string => string,
history: History
};
class Edit extends React.Component<Props> {
componentDidMount() {}
repoModified = () => {
const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.name}`);
};
render() {
const { t, modifyLoading, error } = this.props;
return (
<Page
title={t("edit.title")}
subtitle={t("edit.subtitle")}
error={error}
showContentOnError={true}
>
<RepositoryForm
repository={this.props.repository}
loading={modifyLoading}
submitForm={repo => {
this.props.modifyRepo(repo, this.repoModified);
}}
/>
</Page>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { namespace, name } = ownProps.repository;
const modifyLoading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
modifyLoading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
modifyRepo: (repo: Repository, callback: () => void) => {
dispatch(modifyRepo(repo, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(withRouter(Edit)));

View File

@@ -17,8 +17,10 @@ import { translate } from "react-i18next";
import { Navigation, NavLink, Section } from "../../components/navigation"; import { Navigation, NavLink, Section } from "../../components/navigation";
import RepositoryDetails from "../components/RepositoryDetails"; import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction"; import DeleteNavAction from "../components/DeleteNavAction";
import Edit from "../containers/Edit";
import type { History } from "history"; import type { History } from "history";
import EditNavLink from "../components/EditNavLink";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -91,10 +93,15 @@ class RepositoryRoot extends React.Component<Props> {
exact exact
component={() => <RepositoryDetails repository={repository} />} component={() => <RepositoryDetails repository={repository} />}
/> />
<Route
path={`${url}/edit`}
component={() => <Edit repository={repository} />}
/>
</div> </div>
<div className="column"> <div className="column">
<Navigation> <Navigation>
<Section label={t("repository-root.actions-label")}> <Section label={t("repository-root.actions-label")}>
<EditNavLink repository={repository} editUrl={`${url}/edit`} />
<DeleteNavAction repository={repository} delete={this.delete} /> <DeleteNavAction repository={repository} delete={this.delete} />
<NavLink to="/repos" label={t("repository-root.back-label")} /> <NavLink to="/repos" label={t("repository-root.back-label")} />
</Section> </Section>

View File

@@ -22,6 +22,11 @@ export const CREATE_REPO_SUCCESS = `${CREATE_REPO}_${types.SUCCESS_SUFFIX}`;
export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`; export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`;
export const CREATE_REPO_RESET = `${CREATE_REPO}_${types.RESET_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 DELETE_REPO = "scm/repos/DELETE_REPO"; export const DELETE_REPO = "scm/repos/DELETE_REPO";
export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`;
export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`;
@@ -188,6 +193,54 @@ export function createRepoReset(): Action {
}; };
} }
// modify
export function modifyRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.put(repository._links.update.href, repository, CONTENT_TYPE)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(cause => {
const error = new Error(`failed to modify repo: ${cause.message}`);
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)
};
}
// delete // delete
export function deleteRepo(repository: Repository, callback?: () => void) { export function deleteRepo(repository: Repository, callback?: () => void) {
@@ -288,6 +341,8 @@ export default function reducer(
switch (action.type) { switch (action.type) {
case FETCH_REPOS_SUCCESS: case FETCH_REPOS_SUCCESS:
return normalizeByNamespaceAndName(action.payload); return normalizeByNamespaceAndName(action.payload);
case MODIFY_REPO_SUCCESS:
return reducerByNames(state, action.payload);
case FETCH_REPO_SUCCESS: case FETCH_REPO_SUCCESS:
return reducerByNames(state, action.payload); return reducerByNames(state, action.payload);
default: default:
@@ -359,6 +414,22 @@ export function getCreateRepoFailure(state: Object) {
return getFailure(state, CREATE_REPO); 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( export function isDeleteRepoPending(
state: Object, state: Object,
namespace: string, namespace: string,

View File

@@ -37,7 +37,15 @@ import reducer, {
DELETE_REPO_PENDING, DELETE_REPO_PENDING,
DELETE_REPO_FAILURE, DELETE_REPO_FAILURE,
isDeleteRepoPending, isDeleteRepoPending,
getDeleteRepoFailure getDeleteRepoFailure,
modifyRepo,
MODIFY_REPO_PENDING,
MODIFY_REPO_SUCCESS,
MODIFY_REPO_FAILURE,
MODIFY_REPO,
isModifyRepoPending,
getModifyRepoFailure,
modifyRepoSuccess
} from "./repos"; } from "./repos";
import type { Repository, RepositoryCollection } from "../types/Repositories"; import type { Repository, RepositoryCollection } from "../types/Repositories";
@@ -485,6 +493,64 @@ describe("repos fetch", () => {
expect(actions[1].payload.error).toBeDefined(); expect(actions[1].payload.error).toBeDefined();
}); });
}); });
it("should successfully modify slarti/fjords repo", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204
});
let 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);
});
});
it("should successfully modify slarti/fjords repo and call the callback", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204
});
let 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(called).toBe(true);
});
});
it("should fail modifying on HTTP 500", () => {
fetchMock.putOnce(slartiFjords._links.update.href, {
status: 500
});
let 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", () => { describe("repos reducer", () => {
@@ -521,6 +587,18 @@ describe("repos reducer", () => {
const newState = reducer({}, fetchRepoSuccess(slartiFjords)); const newState = reducer({}, fetchRepoSuccess(slartiFjords));
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords); expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
}); });
it("should update reposByNames", () => {
const oldState = {
byNames: {
"slarti/fjords": slartiFjords
}
};
let slartiFjordsEdited = { ...slartiFjords };
slartiFjordsEdited.description = "I bless the rains down in Africa";
const newState = reducer(oldState, modifyRepoSuccess(slartiFjordsEdited));
expect(newState.byNames["slarti/fjords"]).toEqual(slartiFjordsEdited);
});
}); });
describe("repos selectors", () => { describe("repos selectors", () => {
@@ -635,6 +713,36 @@ describe("repos selectors", () => {
expect(getCreateRepoFailure({})).toBe(undefined); 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 // delete
it("should return true, when delete repo is pending", () => { it("should return true, when delete repo is pending", () => {