Merged in feature/improved-navi (pull request #152)

Feature/improved navi
This commit is contained in:
Sebastian Sdorra
2019-02-06 14:10:37 +00:00
64 changed files with 1385 additions and 1296 deletions

View File

@@ -1,86 +1,87 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Route } from "react-router";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Links } from "@scm-manager/ui-types";
import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components";
import GlobalConfig from "./GlobalConfig";
import type { History } from "history";
import {connect} from "react-redux";
import {compose} from "redux";
import { getLinks } from "../../modules/indexResource";
type Props = {
links: Links,
// context objects
t: string => string,
match: any,
history: History
};
class Config extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { links, t } = this.props;
const url = this.matchedUrl();
const extensionProps = {
links,
url
};
return (
<Page>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={GlobalConfig} />
<ExtensionPoint name="config.route"
props={extensionProps}
renderAll={true}
/>
</div>
<div className="column is-one-quarter">
<Navigation>
<Section label={t("config.navigation-title")}>
<NavLink
to={`${url}`}
label={t("global-config.navigation-label")}
/>
<ExtensionPoint name="config.navigation"
props={extensionProps}
renderAll={true}
/>
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = (state: any) => {
const links = getLinks(state);
return {
links
};
};
export default compose(
connect(mapStateToProps),
translate("config")
)(Config);
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Route } from "react-router";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Links } from "@scm-manager/ui-types";
import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components";
import GlobalConfig from "./GlobalConfig";
import type { History } from "history";
import { connect } from "react-redux";
import { compose } from "redux";
import { getLinks } from "../../modules/indexResource";
type Props = {
links: Links,
// context objects
t: string => string,
match: any,
history: History
};
class Config extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { links, t } = this.props;
const url = this.matchedUrl();
const extensionProps = {
links,
url
};
return (
<Page>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={GlobalConfig} />
<ExtensionPoint
name="config.route"
props={extensionProps}
renderAll={true}
/>
</div>
<div className="column is-one-quarter">
<Navigation>
<Section label={t("config.navigationLabel")}>
<NavLink
to={`${url}`}
label={t("config.globalConfigurationNavLink")}
/>
<ExtensionPoint
name="config.navigation"
props={extensionProps}
renderAll={true}
/>
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = (state: any) => {
const links = getLinks(state);
return {
links
};
};
export default compose(
connect(mapStateToProps),
translate("config")
)(Config);

View File

@@ -78,8 +78,8 @@ class GlobalConfig extends React.Component<Props, State> {
if (error) {
return (
<ErrorPage
title={t("global-config.error-title")}
subtitle={t("global-config.error-subtitle")}
title={t("config.errorTitle")}
subtitle={t("config.errorSubtitle")}
error={error}
configUpdatePermission={configUpdatePermission}
/>
@@ -91,7 +91,7 @@ class GlobalConfig extends React.Component<Props, State> {
return (
<div>
<Title title={t("global-config.title")} />
<Title title={t("config.title")} />
{this.renderConfigChangedNotification()}
<ConfigForm
submitForm={config => this.modifyConfig(config)}

View File

@@ -12,11 +12,13 @@ import {
ErrorPage,
Page,
Navigation,
SubNavigation,
Section,
NavLink
} from "@scm-manager/ui-components";
import ChangeUserPassword from "./ChangeUserPassword";
import ProfileInfo from "./ProfileInfo";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
me: Me,
@@ -57,26 +59,43 @@ class Profile extends React.Component<Props, State> {
);
}
const extensionProps = {
me,
url
};
return (
<Page title={me.displayName}>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
<Route
path={`${url}/password`}
path={`${url}/settings/password`}
render={() => <ChangeUserPassword me={me} />}
/>
</div>
<div className="column">
<Navigation>
<Section label={t("profile.navigation-label")}>
<NavLink to={`${url}`} icon="fas fa-info-circle" label={t("profile.information")} />
</Section>
<Section label={t("profile.actions-label")}>
<Section label={t("profile.navigationLabel")}>
<NavLink
to={`${url}/password`}
label={t("profile.change-password")}
to={`${url}`}
icon="fas fa-info-circle"
label={t("profile.informationNavLink")}
/>
<SubNavigation
to={`${url}/settings/password`}
label={t("profile.settingsNavLink")}
>
<NavLink
to={`${url}/settings/password`}
label={t("profile.changePasswordNavLink")}
/>
<ExtensionPoint
name="profile.subnavigation"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>

View File

@@ -28,30 +28,20 @@ class ProfileInfo extends React.Component<Props, State> {
<div className="media-content">
<table className="table">
<tbody>
<tr>
<td className="has-text-weight-semibold">{t("profile.username")}</td>
<td>{me.name}</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.displayName")}</td>
<td>{me.displayName}</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.mail")}</td>
<td>
<MailLink address={me.mail} />
</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.groups")}</td>
<td className="content">
<ul>
{me.groups.map((group) => {
return <li>{group}</li>;
})}
</ul>
</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.username")}</td>
<td>{me.name}</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.displayName")}</td>
<td>{me.displayName}</td>
</tr>
<tr>
<td className="has-text-weight-semibold">{t("profile.mail")}</td>
<td>
<MailLink address={me.mail} />
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -2,6 +2,7 @@
import React from "react";
import { translate } from "react-i18next";
import {
Subtitle,
AutocompleteAddEntryToTableField,
LabelWithHelpIcon,
MemberNameTable,
@@ -71,59 +72,67 @@ class GroupForm extends React.Component<Props, State> {
};
render() {
const { t, loading } = this.props;
const { loading, t } = this.props;
const { group } = this.state;
let nameField = null;
let subtitle = null;
if (!this.props.group) {
// create new group
nameField = (
<InputField
label={t("group.name")}
errorMessage={t("group-form.name-error")}
errorMessage={t("groupForm.nameError")}
onChange={this.handleGroupNameChange}
value={group.name}
validationError={this.state.nameValidationError}
helpText={t("group-form.help.nameHelpText")}
helpText={t("groupForm.help.nameHelpText")}
/>
);
} else {
// edit existing group
subtitle = <Subtitle subtitle={t("groupForm.subtitle")} />;
}
return (
<form onSubmit={this.submit}>
{nameField}
<Textarea
label={t("group.description")}
errorMessage={t("group-form.description-error")}
onChange={this.handleDescriptionChange}
value={group.description}
validationError={false}
helpText={t("group-form.help.descriptionHelpText")}
/>
<LabelWithHelpIcon
label={t("group.members")}
helpText={t("group-form.help.memberHelpText")}
/>
<MemberNameTable
members={group.members}
memberListChanged={this.memberListChanged}
/>
<>
{subtitle}
<form onSubmit={this.submit}>
{nameField}
<Textarea
label={t("group.description")}
errorMessage={t("groupForm.descriptionError")}
onChange={this.handleDescriptionChange}
value={group.description}
validationError={false}
helpText={t("groupForm.help.descriptionHelpText")}
/>
<LabelWithHelpIcon
label={t("group.members")}
helpText={t("groupForm.help.memberHelpText")}
/>
<MemberNameTable
members={group.members}
memberListChanged={this.memberListChanged}
/>
<AutocompleteAddEntryToTableField
addEntry={this.addMember}
disabled={false}
buttonLabel={t("add-member-button.label")}
fieldLabel={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")}
loadSuggestions={this.props.loadUserSuggestions}
placeholder={t("add-member-autocomplete.placeholder")}
loadingMessage={t("add-member-autocomplete.loading")}
noOptionsMessage={t("add-member-autocomplete.no-options")}
/>
<SubmitButton
disabled={!this.isValid()}
label={t("group-form.submit")}
loading={loading}
/>
</form>
<AutocompleteAddEntryToTableField
addEntry={this.addMember}
disabled={false}
buttonLabel={t("add-member-button.label")}
fieldLabel={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")}
loadSuggestions={this.props.loadUserSuggestions}
placeholder={t("add-member-autocomplete.placeholder")}
loadingMessage={t("add-member-autocomplete.loading")}
noOptionsMessage={t("add-member-autocomplete.no-options")}
/>
<SubmitButton
disabled={!this.isValid()}
label={t("groupForm.submit")}
loading={loading}
/>
</form>
</>
);
}

View File

@@ -1,56 +0,0 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { Group } from "@scm-manager/ui-types";
import { NavAction, confirmAlert } from "@scm-manager/ui-components";
type Props = {
group: Group,
confirmDialog?: boolean,
t: string => string,
deleteGroup: (group: Group) => void
};
export class DeleteGroupNavLink extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleteGroup = () => {
this.props.deleteGroup(this.props.group);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("delete-group-button.confirm-alert.title"),
message: t("delete-group-button.confirm-alert.message"),
buttons: [
{
label: t("delete-group-button.confirm-alert.submit"),
onClick: () => this.deleteGroup(),
},
{
label: t("delete-group-button.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.group._links.delete;
};
render() {
const { confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
if (!this.isDeletable()) {
return null;
}
return <NavAction icon="fas fa-times" label={t("delete-group-button.label")} action={action} />;
}
}
export default translate("groups")(DeleteGroupNavLink);

View File

@@ -1,82 +0,0 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import DeleteGroupNavLink from "./DeleteGroupNavLink";
import { confirmAlert } from "@scm-manager/ui-components";
jest.mock("@scm-manager/ui-components", () => ({
confirmAlert: jest.fn(),
NavAction: require.requireActual("@scm-manager/ui-components").NavAction
}));
describe("DeleteGroupNavLink", () => {
it("should render nothing, if the delete link is missing", () => {
const group = {
_links: {}
};
const navLink = shallow(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const group = {
_links: {
delete: {
href: "/groups"
}
}
};
const navLink = mount(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const group = {
_links: {
delete: {
href: "/groups"
}
}
};
const navLink = mount(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete group function with delete url", () => {
const group = {
_links: {
delete: {
href: "/groups"
}
}
};
let calledUrl = null;
function capture(group) {
calledUrl = group._links.delete.href;
}
const navLink = mount(
<DeleteGroupNavLink
group={group}
confirmDialog={false}
deleteGroup={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/groups");
});
});

View File

@@ -1,29 +1,28 @@
//@flow
import React from "react";
import type { Group } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Group } from "@scm-manager/ui-types";
type Props = {
t: string => string,
group: Group,
editUrl: string,
group: Group
t: string => string
};
type State = {};
class EditGroupNavLink extends React.Component<Props, State> {
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink to={editUrl} icon="fas fa-cog" label={t("edit-group-button.label")} />;
}
class EditGroupNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.group._links.update;
};
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink to={editUrl} label={t("singleGroup.menu.generalNavLink")} />;
}
}
export default translate("groups")(EditGroupNavLink);

View File

@@ -17,7 +17,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPermission()) {
return null;
}
return <NavLink to={permissionsUrl} label={t("set-permissions-button.label")} />;
return <NavLink to={permissionsUrl} label={t("singleGroup.menu.setPermissionsNavLink")} />;
}
hasPermissionToSetPermission = () => {

View File

@@ -1,3 +1,2 @@
export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
export { default as EditGroupNavLink } from "./EditGroupNavLink";
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";

View File

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { Group } from "@scm-manager/ui-types";
import {
Subtitle,
DeleteButton,
confirmAlert,
ErrorNotification
} from "@scm-manager/ui-components";
import {
deleteGroup,
getDeleteGroupFailure,
isDeleteGroupPending
} from "../modules/groups";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { History } from "history";
type Props = {
loading: boolean,
error: Error,
group: Group,
confirmDialog?: boolean,
deleteGroup: (group: Group, callback?: () => void) => void,
// context props
history: History,
t: string => string
};
export class DeleteGroup extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleteGroup = () => {
this.props.deleteGroup(this.props.group, this.groupDeleted);
};
groupDeleted = () => {
this.props.history.push("/groups");
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("deleteGroup.confirmAlert.title"),
message: t("deleteGroup.confirmAlert.message"),
buttons: [
{
label: t("deleteGroup.confirmAlert.submit"),
onClick: () => this.deleteGroup()
},
{
label: t("deleteGroup.confirmAlert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.group._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
if (!this.isDeletable()) {
return null;
}
return (
<>
<Subtitle subtitle={t("deleteGroup.subtitle")} />
<ErrorNotification error={error} />
<div className="columns">
<div className="column">
<DeleteButton
label={t("deleteGroup.button")}
action={action}
loading={loading}
/>
</div>
</div>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isDeleteGroupPending(state, ownProps.group.name);
const error = getDeleteGroupFailure(state, ownProps.group.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
deleteGroup: (group: Group, callback?: () => void) => {
dispatch(deleteGroup(group, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(translate("groups")(DeleteGroup)));

View File

@@ -3,9 +3,9 @@ import React from "react";
import { connect } from "react-redux";
import GroupForm from "../components/GroupForm";
import {
modifyGroup,
getModifyGroupFailure,
isModifyGroupPending,
modifyGroup,
modifyGroupReset
} from "../modules/groups";
import type { History } from "history";
@@ -13,12 +13,13 @@ import { withRouter } from "react-router-dom";
import type { Group } from "@scm-manager/ui-types";
import { ErrorNotification } from "@scm-manager/ui-components";
import { getUserAutoCompleteLink } from "../../modules/indexResource";
import DeleteGroup from "./DeleteGroup";
type Props = {
group: Group,
fetchGroup: (name: string) => void,
modifyGroup: (group: Group, callback?: () => void) => void,
modifyGroupReset: Group => void,
fetchGroup: (name: string) => void,
autocompleteLink: string,
history: History,
loading?: boolean,
@@ -54,7 +55,7 @@ class EditGroup extends React.Component<Props> {
};
render() {
const { group, loading, error } = this.props;
const { loading, error, group } = this.props;
return (
<div>
<ErrorNotification error={error} />
@@ -66,6 +67,8 @@ class EditGroup extends React.Component<Props> {
loading={loading}
loadUserSuggestions={this.loadUserAutocompletion}
/>
<hr />
<DeleteGroup group={group} />
</div>
);
}

View File

@@ -6,33 +6,30 @@ import {
ErrorPage,
Loading,
Navigation,
SubNavigation,
Section,
NavLink
} from "@scm-manager/ui-components";
import { Route } from "react-router";
import { Details } from "./../components/table";
import {
DeleteGroupNavLink,
EditGroupNavLink,
SetPermissionsNavLink
} from "./../components/navLinks";
import type { Group } from "@scm-manager/ui-types";
import type { History } from "history";
import {
deleteGroup,
fetchGroupByName,
getGroupByName,
isFetchGroupPending,
getFetchGroupFailure,
getDeleteGroupFailure,
isDeleteGroupPending
getFetchGroupFailure
} from "../modules/groups";
import { translate } from "react-i18next";
import EditGroup from "./EditGroup";
import { getGroupsLink } from "../../modules/indexResource";
import SetPermissions from "../../permissions/components/SetPermissions";
import {ExtensionPoint} from "@scm-manager/ui-extensions";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
name: string,
@@ -42,7 +39,6 @@ type Props = {
groupLink: string,
// dispatcher functions
deleteGroup: (group: Group, callback?: () => void) => void,
fetchGroupByName: (string, string) => void,
// context objects
@@ -63,14 +59,6 @@ class SingleGroup extends React.Component<Props> {
return url;
};
deleteGroup = (group: Group) => {
this.props.deleteGroup(group, this.groupDeleted);
};
groupDeleted = () => {
this.props.history.push("/groups");
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
@@ -81,8 +69,8 @@ class SingleGroup extends React.Component<Props> {
if (error) {
return (
<ErrorPage
title={t("single-group.error-title")}
subtitle={t("single-group.error-subtitle")}
title={t("singleGroup.errorTitle")}
subtitle={t("singleGroup.errorSubtitle")}
error={error}
/>
);
@@ -109,15 +97,17 @@ class SingleGroup extends React.Component<Props> {
component={() => <Details group={group} />}
/>
<Route
path={`${url}/edit`}
path={`${url}/settings/general`}
exact
component={() => <EditGroup group={group} />}
/>
<Route
path={`${url}/permissions`}
path={`${url}/settings/permissions`}
exact
component={() => (
<SetPermissions selectedPermissionsLink={group._links.permissions} />
<SetPermissions
selectedPermissionsLink={group._links.permissions}
/>
)}
/>
<ExtensionPoint
@@ -128,33 +118,35 @@ class SingleGroup extends React.Component<Props> {
</div>
<div className="column">
<Navigation>
<Section label={t("single-group.navigation-label")}>
<Section label={t("singleGroup.menu.navigationLabel")}>
<NavLink
to={`${url}`}
icon="fas fa-info-circle"
label={t("single-group.information-label")}
/>
<SetPermissionsNavLink
group={group}
permissionsUrl={`${url}/permissions`}
label={t("singleGroup.menu.informationNavLink")}
/>
<ExtensionPoint
name="group.navigation"
props={extensionProps}
renderAll={true}
/>
</Section>
<Section label={t("single-group.actions-label")}>
<DeleteGroupNavLink
group={group}
deleteGroup={this.deleteGroup}
/>
<EditGroupNavLink group={group} editUrl={`${url}/edit`} />
<NavLink
to="/groups"
icon="fas fa-undo-alt"
label={t("single-group.back-label")}
/>
<SubNavigation
to={`${url}/settings/general`}
label={t("singleGroup.menu.settingsNavLink")}
>
<EditGroupNavLink
group={group}
editUrl={`${url}/settings/general`}
/>
<SetPermissionsNavLink
group={group}
permissionsUrl={`${url}/settings/permissions`}
/>
<ExtensionPoint
name="group.subnavigation"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>
@@ -167,10 +159,8 @@ class SingleGroup extends React.Component<Props> {
const mapStateToProps = (state, ownProps) => {
const name = ownProps.match.params.name;
const group = getGroupByName(state, name);
const loading =
isFetchGroupPending(state, name) || isDeleteGroupPending(state, name);
const error =
getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name);
const loading = isFetchGroupPending(state, name);
const error = getFetchGroupFailure(state, name);
const groupLink = getGroupsLink(state);
return {
@@ -186,9 +176,6 @@ const mapDispatchToProps = dispatch => {
return {
fetchGroupByName: (link: string, name: string) => {
dispatch(fetchGroupByName(link, name));
},
deleteGroup: (group: Group, callback?: () => void) => {
dispatch(deleteGroup(group, callback));
}
};
};

View File

@@ -113,7 +113,7 @@ class SetPermissions extends React.Component<Props, State> {
message = (
<Notification
type={"success"}
children={t("form.set-permissions-successful")}
children={t("setPermissions.setPermissionsSuccessful")}
onClose={() => this.onClose()}
/>
);
@@ -128,7 +128,7 @@ class SetPermissions extends React.Component<Props, State> {
<SubmitButton
disabled={!this.state.permissionsChanged}
loading={loading}
label={t("form.submit-button.label")}
label={t("setPermissions.button")}
/>
</form>
);

View File

@@ -1,58 +0,0 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { NavAction, confirmAlert } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
repository: Repository,
confirmDialog?: boolean,
delete: Repository => void,
// context props
t: string => string
};
class DeleteNavAction extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
delete = () => {
this.props.delete(this.props.repository);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("delete-nav-action.confirm-alert.title"),
message: t("delete-nav-action.confirm-alert.message"),
buttons: [
{
label: t("delete-nav-action.confirm-alert.submit"),
onClick: () => this.delete()
},
{
label: t("delete-nav-action.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.repository._links.delete;
};
render() {
const { confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.delete();
if (!this.isDeletable()) {
return null;
}
return <NavAction action={action} icon="fas fa-times" label={t("delete-nav-action.label")} />;
}
}
export default translate("repos")(DeleteNavAction);

View File

@@ -1,82 +0,0 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import DeleteNavAction from "./DeleteNavAction";
import { confirmAlert } from "@scm-manager/ui-components";
jest.mock("@scm-manager/ui-components", () => ({
confirmAlert: jest.fn(),
NavAction: require.requireActual("@scm-manager/ui-components").NavAction
}));
describe("DeleteNavAction", () => {
it("should render nothing, if the delete link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<DeleteNavAction repository={repository} delete={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
delete: {
href: "/repositories"
}
}
};
const navLink = mount(
<DeleteNavAction repository={repository} delete={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const repository = {
_links: {
delete: {
href: "/repositorys"
}
}
};
const navLink = mount(
<DeleteNavAction repository={repository} delete={() => {}} />
);
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete repository function with delete url", () => {
const repository = {
_links: {
delete: {
href: "/repos"
}
}
};
let calledUrl = null;
function capture(repository) {
calledUrl = repository._links.delete.href;
}
const navLink = mount(
<DeleteNavAction
repository={repository}
confirmDialog={false}
delete={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/repos");
});
});

View File

@@ -1,22 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
type Props = { editUrl: string, t: string => string, repository: Repository };
type Props = {
repository: Repository,
editUrl: string,
t: string => string
};
class EditNavLink extends React.Component<Props> {
class EditRepoNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.repository._links.update;
};
render() {
const { editUrl, t } = this.props;
if (!this.isEditable()) {
return null;
}
const { editUrl, t } = this.props;
return <NavLink to={editUrl} icon="fas fa-cog" label={t("edit-nav-link.label")} />;
return <NavLink to={editUrl} label={t("repositoryRoot.menu.generalNavLink")} />;
}
}
export default translate("repos")(EditNavLink);
export default translate("repos")(EditRepoNavLink);

View File

@@ -3,9 +3,9 @@ import { shallow, mount } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import EditNavLink from "./EditNavLink";
import EditRepoNavLink from "./EditRepoNavLink";
describe("EditNavLink", () => {
describe("GeneralNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
@@ -14,7 +14,7 @@ describe("EditNavLink", () => {
};
const navLink = shallow(
<EditNavLink repository={repository} editUrl="" />,
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
@@ -30,9 +30,9 @@ describe("EditNavLink", () => {
};
const navLink = mount(
<EditNavLink repository={repository} editUrl="" />,
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe(" edit-nav-link.label");
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
});
});

View File

@@ -20,7 +20,7 @@ class PermissionsNavLink extends React.Component<Props> {
}
const { permissionUrl, t } = this.props;
return (
<NavLink to={permissionUrl} icon="fas fa-lock" label={t("repository-root.permissions")} />
<NavLink to={permissionUrl} label={t("repositoryRoot.menu.permissionsNavLink")} />
);
}
}

View File

@@ -33,6 +33,6 @@ describe("PermissionsNavLink", () => {
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe(" repository-root.permissions");
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
});
});

View File

@@ -2,6 +2,7 @@
import React from "react";
import { translate } from "react-i18next";
import {
Subtitle,
InputField,
Select,
SubmitButton,
@@ -81,30 +82,39 @@ class RepositoryForm extends React.Component<Props, State> {
const { loading, t } = this.props;
const repository = this.state.repository;
return (
<form onSubmit={this.submit}>
{this.renderCreateOnlyFields()}
<InputField
label={t("repository.contact")}
onChange={this.handleContactChange}
value={repository ? repository.contact : ""}
validationError={this.state.contactValidationError}
errorMessage={t("validation.contact-invalid")}
helpText={t("help.contactHelpText")}
/>
let subtitle = null;
if (this.props.repository) {
// edit existing repo
subtitle = <Subtitle subtitle={t("repositoryForm.subtitle")} />;
}
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
helpText={t("help.descriptionHelpText")}
/>
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("repository-form.submit")}
/>
</form>
return (
<>
{subtitle}
<form onSubmit={this.submit}>
{this.renderCreateOnlyFields()}
<InputField
label={t("repository.contact")}
onChange={this.handleContactChange}
value={repository ? repository.contact : ""}
validationError={this.state.contactValidationError}
errorMessage={t("validation.contact-invalid")}
helpText={t("help.contactHelpText")}
/>
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
helpText={t("help.descriptionHelpText")}
/>
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("repositoryForm.submit")}
/>
</form>
</>
);
}

View File

@@ -64,7 +64,7 @@ class RepositoryEntry extends React.Component<Props> {
return (
<RepositoryEntryLink
iconClass="fa-cog fa-lg"
to={repositoryLink + "/edit"}
to={repositoryLink + "/settings/general"}
/>
);
}

View File

@@ -37,8 +37,8 @@ class ChangesetView extends React.Component<Props> {
if (error) {
return (
<ErrorPage
title={t("changeset-error.title")}
subtitle={t("changeset-error.subtitle")}
title={t("changesets.errorTitle")}
subtitle={t("changesets.errorSubtitle")}
error={error}
/>
);

View File

@@ -101,7 +101,7 @@ class BranchRoot extends React.Component<Props> {
if (repository._links.branches) {
return (
<BranchSelector
label={t("branch-selector.label")}
label={t("changesets.branchSelectorLabel")}
branches={branches}
selectedBranch={selected}
selected={(b: Branch) => {

View File

@@ -0,0 +1,114 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
import {
Subtitle,
DeleteButton,
confirmAlert,
ErrorNotification
} from "@scm-manager/ui-components";
import {
deleteRepo,
getDeleteRepoFailure,
isDeleteRepoPending
} from "../modules/repos";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { History } from "history";
type Props = {
loading: boolean,
error: Error,
repository: Repository,
confirmDialog?: boolean,
deleteRepo: (Repository, () => void) => void,
// context props
history: History,
t: string => string
};
class DeleteRepo extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleted = () => {
this.props.history.push("/repos");
};
deleteRepo = () => {
this.props.deleteRepo(this.props.repository, this.deleted);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("deleteRepo.confirmAlert.title"),
message: t("deleteRepo.confirmAlert.message"),
buttons: [
{
label: t("deleteRepo.confirmAlert.submit"),
onClick: () => this.deleteRepo()
},
{
label: t("deleteRepo.confirmAlert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.repository._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteRepo;
if (!this.isDeletable()) {
return null;
}
return (
<>
<Subtitle subtitle={t("deleteRepo.subtitle")} />
<ErrorNotification error={error} />
<div className="columns">
<div className="column">
<DeleteButton
label={t("deleteRepo.button")}
action={action}
loading={loading}
/>
</div>
</div>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { namespace, name } = ownProps.repository;
const loading = isDeleteRepoPending(state, namespace, name);
const error = getDeleteRepoFailure(state, namespace, name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
deleteRepo: (repo: Repository, callback: () => void) => {
dispatch(deleteRepo(repo, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(translate("repos")(DeleteRepo)));

View File

@@ -1,8 +1,9 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import RepositoryForm from "../components/form";
import DeleteRepo from "./DeleteRepo";
import type { Repository } from "@scm-manager/ui-types";
import {
modifyRepo,
@@ -10,34 +11,55 @@ import {
getModifyRepoFailure,
modifyRepoReset
} from "../modules/repos";
import { withRouter } from "react-router-dom";
import type { History } from "history";
import { ErrorNotification } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
repository: Repository,
modifyRepo: (Repository, () => void) => void,
modifyRepoReset: Repository => void,
loading: boolean,
error: Error,
modifyRepo: (Repository, () => void) => void,
modifyRepoReset: Repository => void,
// context props
t: string => string,
history: History
repository: Repository,
history: History,
match: any
};
class Edit extends React.Component<Props> {
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}`);
};
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { loading, error } = this.props;
const { loading, error, repository } = this.props;
const url = this.matchedUrl();
const extensionProps = {
repository,
url
};
return (
<div>
<ErrorNotification error={error} />
@@ -48,6 +70,13 @@ class Edit extends React.Component<Props> {
this.props.modifyRepo(repo, this.repoModified);
}}
/>
<hr />
<ExtensionPoint
name="repo-config.route"
props={extensionProps}
renderAll={true}
/>
<DeleteRepo repository={repository} />
</div>
);
}
@@ -77,4 +106,4 @@ const mapDispatchToProps = dispatch => {
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(withRouter(Edit)));
)(withRouter(EditRepo));

View File

@@ -90,7 +90,7 @@ class Overview extends React.Component<Props> {
if (showCreateButton) {
return (
<CreateButton
label={t("overview.create-button")}
label={t("overview.createButton")}
link="/repos/create"
/>
);

View File

@@ -1,20 +1,32 @@
//@flow
import React from "react";
import {deleteRepo, fetchRepoByName, getFetchRepoFailure, getRepository, isFetchRepoPending} from "../modules/repos";
import {
fetchRepoByName,
getFetchRepoFailure,
getRepository,
isFetchRepoPending
} from "../modules/repos";
import {connect} from "react-redux";
import {Route, Switch} from "react-router-dom";
import type {Repository} from "@scm-manager/ui-types";
import { connect } from "react-redux";
import { Route, Switch } from "react-router-dom";
import type { Repository } from "@scm-manager/ui-types";
import {ErrorPage, Loading, Navigation, NavLink, Page, Section} from "@scm-manager/ui-components";
import {translate} from "react-i18next";
import {
ErrorPage,
Loading,
Navigation,
SubNavigation,
NavLink,
Page,
Section
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction";
import Edit from "../containers/Edit";
import EditRepo from "./EditRepo";
import Permissions from "../permissions/containers/Permissions";
import type {History} from "history";
import EditNavLink from "../components/EditNavLink";
import type { History } from "history";
import EditRepoNavLink from "../components/EditRepoNavLink";
import BranchRoot from "./ChangesetsRoot";
import ChangesetView from "./ChangesetView";
@@ -35,7 +47,6 @@ type Props = {
// dispatch functions
fetchRepoByName: (link: string, namespace: string, name: string) => void,
deleteRepo: (repository: Repository, () => void) => void,
// context props
t: string => string,
@@ -61,14 +72,6 @@ class RepositoryRoot extends React.Component<Props> {
return this.stripEndingSlash(this.props.match.url);
};
deleted = () => {
this.props.history.push("/repos");
};
delete = (repository: Repository) => {
this.props.deleteRepo(repository, this.deleted);
};
matches = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`);
@@ -81,8 +84,8 @@ class RepositoryRoot extends React.Component<Props> {
if (error) {
return (
<ErrorPage
title={t("repository-root.error-title")}
subtitle={t("repository-root.error-subtitle")}
title={t("repositoryRoot.errorTitle")}
subtitle={t("repositoryRoot.errorSubtitle")}
error={error}
/>
);
@@ -111,11 +114,11 @@ class RepositoryRoot extends React.Component<Props> {
component={() => <RepositoryDetails repository={repository} />}
/>
<Route
path={`${url}/edit`}
component={() => <Edit repository={repository} />}
path={`${url}/settings/general`}
component={() => <EditRepo repository={repository} />}
/>
<Route
path={`${url}/permissions`}
path={`${url}/settings/permissions`}
render={() => (
<Permissions
namespace={this.props.repository.namespace}
@@ -170,14 +173,18 @@ class RepositoryRoot extends React.Component<Props> {
</div>
<div className="column">
<Navigation>
<Section label={t("repository-root.navigation-label")}>
<NavLink to={url} icon="fas fa-info-circle" label={t("repository-root.information")} />
<Section label={t("repositoryRoot.menu.navigationLabel")}>
<NavLink
to={url}
icon="fas fa-info-circle"
label={t("repositoryRoot.menu.informationNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="changesets"
to={`${url}/changesets/`}
icon="fas fa-code-branch"
label={t("repository-root.history")}
label={t("repositoryRoot.menu.historyNavLink")}
activeWhenMatch={this.matches}
activeOnlyWhenExact={false}
/>
@@ -186,23 +193,32 @@ class RepositoryRoot extends React.Component<Props> {
linkName="sources"
to={`${url}/sources`}
icon="fas fa-code"
label={t("repository-root.sources")}
label={t("repositoryRoot.menu.sourcesNavLink")}
activeOnlyWhenExact={false}
/>
<PermissionsNavLink
permissionUrl={`${url}/permissions`}
repository={repository}
/>
<ExtensionPoint
name="repository.navigation"
props={extensionProps}
renderAll={true}
/>
</Section>
<Section label={t("repository-root.actions-label")}>
<DeleteNavAction repository={repository} delete={this.delete} />
<EditNavLink repository={repository} editUrl={`${url}/edit`} />
<NavLink to="/repos" icon="fas fa-undo" label={t("repository-root.back-label")} />
<SubNavigation
to={`${url}/settings/general`}
label={t("repositoryRoot.menu.settingsNavLink")}
>
<EditRepoNavLink
repository={repository}
editUrl={`${url}/settings/general`}
/>
<PermissionsNavLink
permissionUrl={`${url}/settings/permissions`}
repository={repository}
/>
<ExtensionPoint
name="repository.subnavigation"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>
@@ -234,9 +250,6 @@ const mapDispatchToProps = dispatch => {
return {
fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepoByName(link, namespace, name));
},
deleteRepo: (repository: Repository, callback: () => void) => {
dispatch(deleteRepo(repository, callback));
}
};
};

View File

@@ -118,7 +118,7 @@ class Sources extends React.Component<Props> {
<BranchSelector
branches={branches}
selectedBranch={revision}
label={t("branch-selector.label")}
label={t("changesets.branchSelectorLabel")}
selected={(b: Branch) => {
this.branchSelected(b);
}}

View File

@@ -90,7 +90,7 @@ class SetUserPassword extends React.Component<Props, State> {
message = (
<Notification
type={"success"}
children={t("password.set-password-successful")}
children={t("singleUserPassword.setPasswordSuccessful")}
onClose={() => this.onClose()}
/>
);
@@ -108,7 +108,7 @@ class SetUserPassword extends React.Component<Props, State> {
<SubmitButton
disabled={!this.state.passwordValid}
loading={loading}
label={t("user-form.submit")}
label={t("singleUserPassword.button")}
/>
</form>
);

View File

@@ -3,6 +3,7 @@ import React from "react";
import { translate } from "react-i18next";
import type { User } from "@scm-manager/ui-types";
import {
Subtitle,
Checkbox,
InputField,
PasswordConfirmation,
@@ -113,7 +114,9 @@ class UserForm extends React.Component<Props, State> {
let nameField = null;
let passwordChangeField = null;
let subtitle = null;
if (!this.props.user) {
// create new user
nameField = (
<div className="column is-half">
<InputField
@@ -130,59 +133,65 @@ class UserForm extends React.Component<Props, State> {
passwordChangeField = (
<PasswordConfirmation passwordChanged={this.handlePasswordChange} />
);
} else {
// edit existing user
subtitle = <Subtitle subtitle={t("userForm.subtitle")} />;
}
return (
<form onSubmit={this.submit}>
<div className="columns is-multiline">
{nameField}
<div className="column is-half">
<InputField
label={t("user.displayName")}
onChange={this.handleDisplayNameChange}
value={user ? user.displayName : ""}
validationError={this.state.displayNameValidationError}
errorMessage={t("validation.displayname-invalid")}
helpText={t("help.displayNameHelpText")}
/>
<>
{subtitle}
<form onSubmit={this.submit}>
<div className="columns is-multiline">
{nameField}
<div className="column is-half">
<InputField
label={t("user.displayName")}
onChange={this.handleDisplayNameChange}
value={user ? user.displayName : ""}
validationError={this.state.displayNameValidationError}
errorMessage={t("validation.displayname-invalid")}
helpText={t("help.displayNameHelpText")}
/>
</div>
<div className="column is-half">
<InputField
label={t("user.mail")}
onChange={this.handleEmailChange}
value={user ? user.mail : ""}
validationError={this.state.mailValidationError}
errorMessage={t("validation.mail-invalid")}
helpText={t("help.mailHelpText")}
/>
</div>
</div>
<div className="column is-half">
<InputField
label={t("user.mail")}
onChange={this.handleEmailChange}
value={user ? user.mail : ""}
validationError={this.state.mailValidationError}
errorMessage={t("validation.mail-invalid")}
helpText={t("help.mailHelpText")}
/>
<div className="columns">
<div className="column">
{passwordChangeField}
<Checkbox
label={t("user.admin")}
onChange={this.handleAdminChange}
checked={user ? user.admin : false}
helpText={t("help.adminHelpText")}
/>
<Checkbox
label={t("user.active")}
onChange={this.handleActiveChange}
checked={user ? user.active : false}
helpText={t("help.activeHelpText")}
/>
</div>
</div>
</div>
<div className="columns">
<div className="column">
{passwordChangeField}
<Checkbox
label={t("user.admin")}
onChange={this.handleAdminChange}
checked={user ? user.admin : false}
helpText={t("help.adminHelpText")}
/>
<Checkbox
label={t("user.active")}
onChange={this.handleActiveChange}
checked={user ? user.active : false}
helpText={t("help.activeHelpText")}
/>
<div className="columns">
<div className="column">
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("userForm.button")}
/>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("user-form.submit")}
/>
</div>
</div>
</form>
</form>
</>
);
}

View File

@@ -1,20 +0,0 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { CreateButton } from "@scm-manager/ui-components";
// TODO remove
type Props = {
t: string => string
};
class CreateUserButton extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<CreateButton label={t("create-user-button.label")} link="/users/add" />
);
}
}
export default translate("users")(CreateUserButton);

View File

@@ -1,56 +0,0 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "@scm-manager/ui-types";
import { NavAction, confirmAlert } from "@scm-manager/ui-components";
type Props = {
user: User,
confirmDialog?: boolean,
t: string => string,
deleteUser: (user: User) => void
};
class DeleteUserNavLink extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleteUser = () => {
this.props.deleteUser(this.props.user);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("delete-user-button.confirm-alert.title"),
message: t("delete-user-button.confirm-alert.message"),
buttons: [
{
label: t("delete-user-button.confirm-alert.submit"),
onClick: () => this.deleteUser()
},
{
label: t("delete-user-button.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.user._links.delete;
};
render() {
const { confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
if (!this.isDeletable()) {
return null;
}
return <NavAction icon="fas fa-times" label={t("delete-user-button.label")} action={action} />;
}
}
export default translate("users")(DeleteUserNavLink);

View File

@@ -1,82 +0,0 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import DeleteUserNavLink from "./DeleteUserNavLink";
import { confirmAlert } from "@scm-manager/ui-components";
jest.mock("@scm-manager/ui-components", () => ({
confirmAlert: jest.fn(),
NavAction: require.requireActual("@scm-manager/ui-components").NavAction
}));
describe("DeleteUserNavLink", () => {
it("should render nothing, if the delete link is missing", () => {
const user = {
_links: {}
};
const navLink = shallow(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete user function with delete url", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
let calledUrl = null;
function capture(user) {
calledUrl = user._links.delete.href;
}
const navLink = mount(
<DeleteUserNavLink
user={user}
confirmDialog={false}
deleteUser={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/users");
});
});

View File

@@ -1,28 +1,28 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
t: string => string,
user: User,
editUrl: String
editUrl: String,
t: string => string
};
class EditUserNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.user._links.update;
};
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink to={editUrl} icon="fas fa-cog" label={t("edit-user-button.label")} />;
return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} />;
}
isEditable = () => {
return this.props.user._links.update;
};
}
export default translate("users")(EditUserNavLink);

View File

@@ -17,7 +17,7 @@ class ChangePasswordNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPassword()) {
return null;
}
return <NavLink to={passwordUrl} label={t("set-password-button.label")} />;
return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} />;
}
hasPermissionToSetPassword = () => {

View File

@@ -17,7 +17,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPermission()) {
return null;
}
return <NavLink to={permissionsUrl} label={t("set-permissions-button.label")} />;
return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} />;
}
hasPermissionToSetPermission = () => {

View File

@@ -1,4 +1,3 @@
export { default as DeleteUserNavLink } from "./DeleteUserNavLink";
export { default as EditUserNavLink } from "./EditUserNavLink";
export { default as SetPasswordNavLink } from "./SetPasswordNavLink";
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";

View File

@@ -49,8 +49,8 @@ class AddUser extends React.Component<Props> {
return (
<Page
title={t("add-user.title")}
subtitle={t("add-user.subtitle")}
title={t("addUser.title")}
subtitle={t("addUser.subtitle")}
error={error}
showContentOnError={true}
>

View File

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "@scm-manager/ui-types";
import {
Subtitle,
DeleteButton,
confirmAlert,
ErrorNotification
} from "@scm-manager/ui-components";
import {
deleteUser,
getDeleteUserFailure,
isDeleteUserPending
} from "../modules/users";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { History } from "history";
type Props = {
loading: boolean,
error: Error,
user: User,
confirmDialog?: boolean,
deleteUser: (user: User, callback?: () => void) => void,
// context props
history: History,
t: string => string
};
class DeleteUser extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
userDeleted = () => {
this.props.history.push("/users");
};
deleteUser = () => {
this.props.deleteUser(this.props.user, this.userDeleted);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("deleteUser.confirmAlert.title"),
message: t("deleteUser.confirmAlert.message"),
buttons: [
{
label: t("deleteUser.confirmAlert.submit"),
onClick: () => this.deleteUser()
},
{
label: t("deleteUser.confirmAlert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.user._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
if (!this.isDeletable()) {
return null;
}
return (
<>
<Subtitle subtitle={t("deleteUser.subtitle")} />
<ErrorNotification error={error} />
<div className="columns">
<div className="column">
<DeleteButton
label={t("deleteUser.button")}
action={action}
loading={loading}
/>
</div>
</div>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isDeleteUserPending(state, ownProps.user.name);
const error = getDeleteUserFailure(state, ownProps.user.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(translate("users")(DeleteUser)));

View File

@@ -2,7 +2,8 @@
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import UserForm from "./../components/UserForm";
import UserForm from "../components/UserForm";
import DeleteUser from "./DeleteUser";
import type { User } from "@scm-manager/ui-types";
import {
modifyUser,
@@ -31,6 +32,7 @@ class EditUser extends React.Component<Props> {
const { modifyUserReset, user } = this.props;
modifyUserReset(user);
}
userModified = (user: User) => () => {
this.props.history.push(`/user/${user.name}`);
};
@@ -49,11 +51,22 @@ class EditUser extends React.Component<Props> {
user={user}
loading={loading}
/>
<hr />
<DeleteUser user={user} />
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isModifyUserPending(state, ownProps.user.name);
const error = getModifyUserFailure(state, ownProps.user.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
modifyUser: (user: User, callback?: () => void) => {
@@ -65,15 +78,6 @@ const mapDispatchToProps = dispatch => {
};
};
const mapStateToProps = (state, ownProps) => {
const loading = isModifyUserPending(state, ownProps.user.name);
const error = getModifyUserFailure(state, ownProps.user.name);
return {
loading,
error
};
};
export default connect(
mapStateToProps,
mapDispatchToProps

View File

@@ -5,6 +5,7 @@ import {
Page,
Loading,
Navigation,
SubNavigation,
Section,
NavLink,
ErrorPage
@@ -16,24 +17,16 @@ import type { User } from "@scm-manager/ui-types";
import type { History } from "history";
import {
fetchUserByName,
deleteUser,
getUserByName,
isFetchUserPending,
getFetchUserFailure,
isDeleteUserPending,
getDeleteUserFailure
getFetchUserFailure
} from "../modules/users";
import {
DeleteUserNavLink,
EditUserNavLink,
SetPasswordNavLink,
SetPermissionsNavLink
} from "./../components/navLinks";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink } from "./../components/navLinks";
import { translate } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import {ExtensionPoint} from "@scm-manager/ui-extensions";
type Props = {
name: string,
@@ -42,8 +35,7 @@ type Props = {
error: Error,
usersLink: string,
// dispatcher functions
deleteUser: (user: User, callback?: () => void) => void,
// dispatcher function
fetchUserByName: (string, string) => void,
// context objects
@@ -57,14 +49,6 @@ class SingleUser extends React.Component<Props> {
this.props.fetchUserByName(this.props.usersLink, this.props.name);
}
userDeleted = () => {
this.props.history.push("/users");
};
deleteUser = (user: User) => {
this.props.deleteUser(user, this.userDeleted);
};
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
@@ -82,8 +66,8 @@ class SingleUser extends React.Component<Props> {
if (error) {
return (
<ErrorPage
title={t("single-user.error-title")}
subtitle={t("single-user.error-subtitle")}
title={t("singleUser.errorTitle")}
subtitle={t("singleUser.errorSubtitle")}
error={error}
/>
);
@@ -95,21 +79,26 @@ class SingleUser extends React.Component<Props> {
const url = this.matchedUrl();
const extensionProps = {
user,
url
};
return (
<Page title={user.displayName}>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={() => <Details user={user} />} />
<Route
path={`${url}/edit`}
path={`${url}/settings/general`}
component={() => <EditUser user={user} />}
/>
<Route
path={`${url}/password`}
path={`${url}/settings/password`}
component={() => <SetUserPassword user={user} />}
/>
<Route
path={`${url}/permissions`}
path={`${url}/settings/permissions`}
component={() => (
<SetPermissions
selectedPermissionsLink={user._links.permissions}
@@ -119,25 +108,34 @@ class SingleUser extends React.Component<Props> {
</div>
<div className="column">
<Navigation>
<Section label={t("single-user.navigation-label")}>
<Section label={t("singleUser.menu.navigationLabel")}>
<NavLink
to={`${url}`}
icon="fas fa-info-circle"
label={t("single-user.information-label")}
label={t("singleUser.menu.informationNavLink")}
/>
<EditUserNavLink user={user} editUrl={`${url}/edit`} />
<SetPasswordNavLink
user={user}
passwordUrl={`${url}/password`}
/>
<SetPermissionsNavLink
user={user}
permissionsUrl={`${url}/permissions`}
/>
</Section>
<Section label={t("single-user.actions-label")}>
<DeleteUserNavLink user={user} deleteUser={this.deleteUser} />
<NavLink to="/users" icon="fas fa-undo" label={t("single-user.back-label")} />
<SubNavigation
to={`${url}/settings/general`}
label={t("singleUser.menu.settingsNavLink")}
>
<EditUserNavLink
user={user}
editUrl={`${url}/settings/general`}
/>
<SetPasswordNavLink
user={user}
passwordUrl={`${url}/settings/password`}
/>
<SetPermissionsNavLink
user={user}
permissionsUrl={`${url}/settings/permissions`}
/>
<ExtensionPoint
name="user.subnavigation"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>
@@ -150,10 +148,8 @@ class SingleUser extends React.Component<Props> {
const mapStateToProps = (state, ownProps) => {
const name = ownProps.match.params.name;
const user = getUserByName(state, name);
const loading =
isFetchUserPending(state, name) || isDeleteUserPending(state, name);
const error =
getFetchUserFailure(state, name) || getDeleteUserFailure(state, name);
const loading = isFetchUserPending(state, name);
const error = getFetchUserFailure(state, name);
const usersLink = getUsersLink(state);
return {
usersLink,
@@ -168,9 +164,6 @@ const mapDispatchToProps = dispatch => {
return {
fetchUserByName: (link: string, name: string) => {
dispatch(fetchUserByName(link, name));
},
deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback));
}
};
};

View File

@@ -1,143 +1,143 @@
// @flow
import React from "react";
import type { History } from "history";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import {
fetchUsersByPage,
fetchUsersByLink,
getUsersFromState,
selectListAsCollection,
isPermittedToCreateUsers,
isFetchUsersPending,
getFetchUsersFailure
} from "../modules/users";
import { Page, Paginator } from "@scm-manager/ui-components";
import { UserTable } from "./../components/table";
import type { User, PagedCollection } from "@scm-manager/ui-types";
import CreateUserButton from "../components/buttons/CreateUserButton";
import { getUsersLink } from "../../modules/indexResource";
type Props = {
users: User[],
loading: boolean,
error: Error,
canAddUsers: boolean,
list: PagedCollection,
page: number,
usersLink: string,
// context objects
t: string => string,
history: History,
// dispatch functions
fetchUsersByPage: (link: string, page: number) => void,
fetchUsersByLink: (link: string) => void
};
class Users extends React.Component<Props> {
componentDidMount() {
this.props.fetchUsersByPage(this.props.usersLink, this.props.page);
}
onPageChange = (link: string) => {
this.props.fetchUsersByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate() {
const { page, list } = this.props;
if (list && (list.page || list.page === 0)) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {
this.props.history.push(`/users/${statePage}`);
}
}
}
render() {
const { users, loading, error, t } = this.props;
return (
<Page
title={t("users.title")}
subtitle={t("users.subtitle")}
loading={loading || !users}
error={error}
>
<UserTable users={users} />
{this.renderPaginator()}
{this.renderCreateButton()}
</Page>
);
}
renderPaginator() {
const { list } = this.props;
if (list) {
return <Paginator collection={list} onPageChange={this.onPageChange} />;
}
return null;
}
renderCreateButton() {
if (this.props.canAddUsers) {
return <CreateUserButton />;
} else {
return;
}
}
}
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const users = getUsersFromState(state);
const loading = isFetchUsersPending(state);
const error = getFetchUsersFailure(state);
const usersLink = getUsersLink(state);
const page = getPageFromProps(ownProps);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
return {
users,
loading,
error,
canAddUsers,
list,
page,
usersLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUsersByPage: (link: string, page: number) => {
dispatch(fetchUsersByPage(link, page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("users")(Users));
// @flow
import React from "react";
import type { History } from "history";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import {
fetchUsersByPage,
fetchUsersByLink,
getUsersFromState,
selectListAsCollection,
isPermittedToCreateUsers,
isFetchUsersPending,
getFetchUsersFailure
} from "../modules/users";
import { Page, CreateButton, Paginator } from "@scm-manager/ui-components";
import { UserTable } from "./../components/table";
import type { User, PagedCollection } from "@scm-manager/ui-types";
import { getUsersLink } from "../../modules/indexResource";
type Props = {
users: User[],
loading: boolean,
error: Error,
canAddUsers: boolean,
list: PagedCollection,
page: number,
usersLink: string,
// context objects
t: string => string,
history: History,
// dispatch functions
fetchUsersByPage: (link: string, page: number) => void,
fetchUsersByLink: (link: string) => void
};
class Users extends React.Component<Props> {
componentDidMount() {
this.props.fetchUsersByPage(this.props.usersLink, this.props.page);
}
onPageChange = (link: string) => {
this.props.fetchUsersByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate() {
const { page, list } = this.props;
if (list && (list.page || list.page === 0)) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {
this.props.history.push(`/users/${statePage}`);
}
}
}
render() {
const { users, loading, error, t } = this.props;
return (
<Page
title={t("users.title")}
subtitle={t("users.subtitle")}
loading={loading || !users}
error={error}
>
<UserTable users={users} />
{this.renderPaginator()}
{this.renderCreateButton()}
</Page>
);
}
renderPaginator() {
const { list } = this.props;
if (list) {
return <Paginator collection={list} onPageChange={this.onPageChange} />;
}
return null;
}
renderCreateButton() {
const { t } = this.props;
if (this.props.canAddUsers) {
return <CreateButton label={t("users.createButton")} link="/users/add" />;
} else {
return;
}
}
}
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const users = getUsersFromState(state);
const loading = isFetchUsersPending(state);
const error = getFetchUsersFailure(state);
const usersLink = getUsersLink(state);
const page = getPageFromProps(ownProps);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
return {
users,
loading,
error,
canAddUsers,
list,
page,
usersLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUsersByPage: (link: string, page: number) => {
dispatch(fetchUsersByPage(link, page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("users")(Users));