scm-ui: new repository layout

This commit is contained in:
Sebastian Sdorra
2019-10-07 10:57:09 +02:00
parent 09c7def874
commit c05798e254
417 changed files with 3620 additions and 52971 deletions

View File

@@ -0,0 +1,124 @@
// @flow
import React, { Component } from "react";
import Main from "./Main";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import {
fetchMe,
isAuthenticated,
getMe,
isFetchMePending,
getFetchMeFailure
} from "../modules/auth";
import {
PrimaryNavigation,
Loading,
ErrorPage,
Footer,
Header
} from "@scm-manager/ui-components";
import type { Links, Me } from "@scm-manager/ui-types";
import {
getFetchIndexResourcesFailure,
getLinks,
getMeLink,
isFetchIndexResourcesPending
} from "../modules/indexResource";
type Props = {
me: Me,
authenticated: boolean,
error: Error,
loading: boolean,
links: Links,
meLink: string,
// dispatcher functions
fetchMe: (link: string) => void,
// context props
t: string => string
};
class App extends Component<Props> {
componentDidMount() {
if (this.props.meLink) {
this.props.fetchMe(this.props.meLink);
}
}
render() {
const {
me,
loading,
error,
authenticated,
links,
t
} = this.props;
let content;
const navigation = authenticated ? (
<PrimaryNavigation
links={links}
/>
) : (
""
);
if (loading) {
content = <Loading />;
} else if (error) {
content = (
<ErrorPage
title={t("app.error.title")}
subtitle={t("app.error.subtitle")}
error={error}
/>
);
} else {
content = <Main authenticated={authenticated} links={links} />;
}
return (
<div className="App">
<Header>{navigation}</Header>
{content}
<Footer me={me} />
</div>
);
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchMe: (link: string) => dispatch(fetchMe(link))
};
};
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const me = getMe(state);
const loading =
isFetchMePending(state) || isFetchIndexResourcesPending(state);
const error =
getFetchMeFailure(state) || getFetchIndexResourcesFailure(state);
const links = getLinks(state);
const meLink = getMeLink(state);
return {
authenticated,
me,
loading,
error,
links,
meLink
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(App))
);

View File

@@ -0,0 +1,159 @@
// @flow
import React from "react";
import {
ErrorNotification,
InputField,
Notification,
PasswordConfirmation,
SubmitButton
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Me } from "@scm-manager/ui-types";
import { changePassword } from "../modules/changePassword";
type Props = {
me: Me,
t: string => string
};
type State = {
oldPassword: string,
password: string,
loading: boolean,
error?: Error,
passwordChanged: boolean,
passwordValid: boolean
};
class ChangeUserPassword extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
oldPassword: "",
password: "",
loading: false,
passwordConfirmationError: false,
validatePasswordError: false,
validatePassword: "",
passwordChanged: false,
passwordValid: false
};
}
setLoadingState = () => {
this.setState({
...this.state,
loading: true
});
};
setErrorState = (error: Error) => {
this.setState({
...this.state,
error: error,
loading: false
});
};
setSuccessfulState = () => {
this.setState({
...this.state,
loading: false,
passwordChanged: true,
oldPassword: "",
password: ""
});
};
submit = (event: Event) => {
event.preventDefault();
if (this.state.password) {
const { oldPassword, password } = this.state;
this.setLoadingState();
changePassword(this.props.me._links.password.href, oldPassword, password)
.then(result => {
if (result.error) {
this.setErrorState(result.error);
} else {
this.setSuccessfulState();
}
})
.catch(err => {
this.setErrorState(err);
});
}
};
isValid = () => {
return this.state.oldPassword && this.state.passwordValid;
};
render() {
const { t } = this.props;
const { loading, passwordChanged, error } = this.state;
let message = null;
if (passwordChanged) {
message = (
<Notification
type={"success"}
children={t("password.changedSuccessfully")}
onClose={() => this.onClose()}
/>
);
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={this.submit}>
{message}
<div className="columns">
<div className="column">
<InputField
label={t("password.currentPassword")}
type="password"
onChange={oldPassword =>
this.setState({ ...this.state, oldPassword })
}
value={this.state.oldPassword ? this.state.oldPassword : ""}
helpText={t("password.currentPasswordHelpText")}
/>
</div>
</div>
<PasswordConfirmation
passwordChanged={this.passwordChanged}
key={this.state.passwordChanged ? "changed" : "unchanged"}
/>
<div className="columns">
<div className="column">
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("password.submit")}
/>
</div>
</div>
</form>
);
}
passwordChanged = (password: string, passwordValid: boolean) => {
this.setState({
...this.state,
password,
passwordValid: !!password && passwordValid
});
};
onClose = () => {
this.setState({
...this.state,
passwordChanged: false
});
};
}
export default translate("commons")(ChangeUserPassword);

View File

@@ -0,0 +1,101 @@
// @flow
import React, { Component } from "react";
import App from "./App";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import { Loading, ErrorBoundary } from "@scm-manager/ui-components";
import {
fetchIndexResources,
getFetchIndexResourcesFailure,
getLinks,
isFetchIndexResourcesPending
} from "../modules/indexResource";
import PluginLoader from "./PluginLoader";
import type { IndexResources } from "@scm-manager/ui-types";
import ScrollToTop from "./ScrollToTop";
import IndexErrorPage from "./IndexErrorPage";
type Props = {
error: Error,
loading: boolean,
indexResources: IndexResources,
// dispatcher functions
fetchIndexResources: () => void,
// context props
t: string => string
};
type State = {
pluginsLoaded: boolean
};
class Index extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
pluginsLoaded: false
};
}
componentDidMount() {
this.props.fetchIndexResources();
}
pluginLoaderCallback = () => {
this.setState({
pluginsLoaded: true
});
};
render() {
const { indexResources, loading, error } = this.props;
const { pluginsLoaded } = this.state;
if (error) {
return <IndexErrorPage error={error}/>;
} else if (loading || !indexResources) {
return <Loading />;
} else {
return (
<ErrorBoundary fallback={IndexErrorPage}>
<ScrollToTop>
<PluginLoader
loaded={pluginsLoaded}
callback={this.pluginLoaderCallback}
>
<App />
</PluginLoader>
</ScrollToTop>
</ErrorBoundary>
);
}
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchIndexResources: () => dispatch(fetchIndexResources())
};
};
const mapStateToProps = state => {
const loading = isFetchIndexResourcesPending(state);
const error = getFetchIndexResourcesFailure(state);
const indexResources = getLinks(state);
return {
loading,
error,
indexResources
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(Index))
);

View File

@@ -0,0 +1,26 @@
//@flow
import React from "react";
import { translate, type TFunction } from "react-i18next";
import { ErrorPage } from "@scm-manager/ui-components";
type Props = {
error: Error,
t: TFunction
}
class IndexErrorPage extends React.Component<Props> {
render() {
const { error, t } = this.props;
return (
<ErrorPage
title={t("app.error.title")}
subtitle={t("app.error.subtitle")}
error={error}
/>
);
}
}
export default translate("commons")(IndexErrorPage);

View File

@@ -0,0 +1,100 @@
//@flow
import React from "react";
import { Redirect, withRouter } from "react-router-dom";
import {
login,
isAuthenticated,
isLoginPending,
getLoginFailure
} from "../modules/auth";
import { connect } from "react-redux";
import { getLoginLink, getLoginInfoLink } from "../modules/indexResource";
import LoginInfo from "../components/LoginInfo";
import classNames from "classnames";
import injectSheet from "react-jss";
const styles = {
section: {
paddingTop: "2em"
}
};
type Props = {
authenticated: boolean,
loading: boolean,
error?: Error,
link: string,
loginInfoLink?: string,
// dispatcher props
login: (link: string, username: string, password: string) => void,
// context props
classes: any,
t: string => string,
from: any,
location: any
};
class Login extends React.Component<Props> {
handleLogin = (username: string, password: string): void => {
const { link, login } = this.props;
login(link, username, password);
};
renderRedirect = () => {
const { from } = this.props.location.state || { from: { pathname: "/" } };
return <Redirect to={from}/>;
};
render() {
const { authenticated, classes, ...restProps } = this.props;
if (authenticated) {
return this.renderRedirect();
}
return (
<section className={classNames("hero", classes.section )}>
<div className="hero-body">
<div className="container">
<div className="columns is-centered">
<LoginInfo loginHandler={this.handleLogin} {...restProps} />
</div>
</div>
</div>
</section>
);
}
}
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const loading = isLoginPending(state);
const error = getLoginFailure(state);
const link = getLoginLink(state);
const loginInfoLink = getLoginInfoLink(state);
return {
authenticated,
loading,
error,
link,
loginInfoLink
};
};
const mapDispatchToProps = dispatch => {
return {
login: (loginLink: string, username: string, password: string) =>
dispatch(login(loginLink, username, password))
};
};
const StyledLogin = injectSheet(styles)(
connect(
mapStateToProps,
mapDispatchToProps
)(Login)
);
export default withRouter(StyledLogin);

View File

@@ -0,0 +1,77 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Redirect } from "react-router-dom";
import {
logout,
isAuthenticated,
isLogoutPending,
getLogoutFailure, isRedirecting
} from "../modules/auth";
import { Loading, ErrorPage } from "@scm-manager/ui-components";
import { getLogoutLink } from "../modules/indexResource";
type Props = {
authenticated: boolean,
loading: boolean,
redirecting: boolean,
error: Error,
logoutLink: string,
// dispatcher functions
logout: (link: string) => void,
// context props
t: string => string
};
class Logout extends React.Component<Props> {
componentDidMount() {
this.props.logout(this.props.logoutLink);
}
render() {
const { authenticated, redirecting, loading, error, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("logout.error.title")}
subtitle={t("logout.error.subtitle")}
error={error}
/>
);
} else if (loading || authenticated || redirecting) {
return <Loading />;
} else {
return <Redirect to="/login" />;
}
}
}
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const loading = isLogoutPending(state);
const redirecting = isRedirecting(state);
const error = getLogoutFailure(state);
const logoutLink = getLogoutLink(state);
return {
authenticated,
loading,
redirecting,
error,
logoutLink
};
};
const mapDispatchToProps = dispatch => {
return {
logout: (link: string) => dispatch(logout(link))
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(Logout));

View File

@@ -0,0 +1,138 @@
//@flow
import React from "react";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import type { Links } from "@scm-manager/ui-types";
import Overview from "../repos/containers/Overview";
import Users from "../users/containers/Users";
import Login from "../containers/Login";
import Logout from "../containers/Logout";
import { ProtectedRoute } from "@scm-manager/ui-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import CreateUser from "../users/containers/CreateUser";
import SingleUser from "../users/containers/SingleUser";
import RepositoryRoot from "../repos/containers/RepositoryRoot";
import Create from "../repos/containers/Create";
import Groups from "../groups/containers/Groups";
import SingleGroup from "../groups/containers/SingleGroup";
import CreateGroup from "../groups/containers/CreateGroup";
import Admin from "../admin/containers/Admin";
import Profile from "./Profile";
type Props = {
authenticated?: boolean,
links: Links
};
class Main extends React.Component<Props> {
render() {
const { authenticated, links } = this.props;
const redirectUrlFactory = binder.getExtension("main.redirect", this.props);
let url = "/repos/";
if (redirectUrlFactory) {
url = redirectUrlFactory(this.props);
}
return (
<div className="main">
<Switch>
<Redirect exact from="/" to={url} />
<Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Redirect exact strict from="/repos" to="/repos/" />
<ProtectedRoute
exact
path="/repos/"
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repos/create"
component={Create}
authenticated={authenticated}
/>
<ProtectedRoute
exact
path="/repos/:page"
component={Overview}
authenticated={authenticated}
/>
<ProtectedRoute
path="/repo/:namespace/:name"
component={RepositoryRoot}
authenticated={authenticated}
/>
<Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute
exact
path="/users/"
component={Users}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/users/create"
component={CreateUser}
/>
<ProtectedRoute
exact
path="/users/:page"
component={Users}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/user/:name"
component={SingleUser}
/>
<Redirect exact strict from="/groups" to="/groups/" />
<ProtectedRoute
exact
path="/groups/"
component={Groups}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/group/:name"
component={SingleGroup}
/>
<ProtectedRoute
authenticated={authenticated}
path="/groups/create"
component={CreateGroup}
/>
<ProtectedRoute
exact
path="/groups/:page"
component={Groups}
authenticated={authenticated}
/>
<ProtectedRoute
path="/admin"
component={Admin}
authenticated={authenticated}
/>
<ProtectedRoute
path="/me"
component={Profile}
authenticated={authenticated}
/>
<ExtensionPoint
name="main.route"
renderAll={true}
props={{ authenticated, links }}
/>
</Switch>
</div>
);
}
}
export default withRouter(Main);

View File

@@ -0,0 +1,125 @@
// @flow
import * as React from "react";
import { apiClient, Loading } from "@scm-manager/ui-components";
import { getUiPluginsLink } from "../modules/indexResource";
import { connect } from "react-redux";
type Props = {
loaded: boolean,
children: React.Node,
link: string,
callback: () => void
};
type State = {
message: string
};
type Plugin = {
name: string,
bundles: string[]
};
class PluginLoader extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
message: "booting"
};
}
componentDidMount() {
const { loaded } = this.props;
if (!loaded) {
this.setState({
message: "loading plugin information"
});
this.getPlugins(this.props.link);
}
}
getPlugins = (link: string): Promise<any> => {
return apiClient
.get(link)
.then(response => response.text())
.then(JSON.parse)
.then(pluginCollection => pluginCollection._embedded.plugins)
.then(this.loadPlugins)
.then(this.props.callback);
};
loadPlugins = (plugins: Plugin[]) => {
this.setState({
message: "loading plugins"
});
const promises = [];
const sortedPlugins = plugins.sort(comparePluginsByName);
for (let plugin of sortedPlugins) {
promises.push(this.loadPlugin(plugin));
}
return promises.reduce((chain, current) => {
return chain.then(chainResults => {
return current.then(currentResult => [...chainResults, currentResult]);
});
}, Promise.resolve([]));
};
loadPlugin = (plugin: Plugin) => {
this.setState({
message: `loading ${plugin.name}`
});
const promises = [];
for (let bundle of plugin.bundles) {
promises.push(this.loadBundle(bundle));
}
return Promise.all(promises);
};
loadBundle = (bundle: string) => {
return fetch(bundle, {
credentials: "same-origin",
headers: {
Cache: "no-cache",
// identify the request as ajax request
"X-Requested-With": "XMLHttpRequest"
}
})
.then(response => {
return response.text();
})
.then(script => {
// TODO is this safe???
// eslint-disable-next-line no-eval
eval(script); // NOSONAR
});
};
render() {
const { loaded } = this.props;
const { message } = this.state;
if (loaded) {
return <div>{this.props.children}</div>;
}
return <Loading message={message} />;
}
}
const comparePluginsByName = (a: Plugin, b: Plugin) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
};
const mapStateToProps = state => {
const link = getUiPluginsLink(state);
return {
link
};
};
export default connect(mapStateToProps)(PluginLoader);

View File

@@ -0,0 +1,123 @@
// @flow
import React from "react";
import { Route, withRouter } from "react-router-dom";
import { getMe } from "../modules/auth";
import { compose } from "redux";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import type { Me } from "@scm-manager/ui-types";
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,
// Context props
t: string => string,
match: any
};
type State = {};
class Profile extends React.Component<Props, State> {
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 url = this.matchedUrl();
const { me, t } = this.props;
if (!me) {
return (
<ErrorPage
title={t("profile.error-title")}
subtitle={t("profile.error-subtitle")}
error={{
name: t("profile.error"),
message: t("profile.error-message")
}}
/>
);
}
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}/settings/password`}
render={() => <ChangeUserPassword me={me} />}
/>
<ExtensionPoint
name="profile.route"
props={extensionProps}
renderAll={true}
/>
</div>
<div className="column">
<Navigation>
<Section label={t("profile.navigationLabel")}>
<NavLink
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.setting"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = state => {
return {
me: getMe(state)
};
};
export default compose(
translate("commons"),
connect(mapStateToProps),
withRouter
)(Profile);

View File

@@ -0,0 +1,89 @@
// @flow
import React from "react";
import type { Me } from "@scm-manager/ui-types";
import {
MailLink,
AvatarWrapper,
AvatarImage
} from "@scm-manager/ui-components";
import { compose } from "redux";
import { translate } from "react-i18next";
import injectSheet from "react-jss";
type Props = {
me: Me,
// Context props
classes: any,
t: string => string
};
const styles = {
spacing: {
padding: "0 !important"
}
};
class ProfileInfo extends React.Component<Props> {
render() {
const { me, t } = this.props;
return (
<div className="media">
<AvatarWrapper>
<figure className="media-left">
<p className="image is-64x64">
<AvatarImage person={me} />
</p>
</figure>
</AvatarWrapper>
<div className="media-content">
<table className="table content">
<tbody>
<tr>
<th>{t("profile.username")}</th>
<td>{me.name}</td>
</tr>
<tr>
<th>{t("profile.displayName")}</th>
<td>{me.displayName}</td>
</tr>
<tr>
<th>{t("profile.mail")}</th>
<td>
<MailLink address={me.mail} />
</td>
</tr>
{this.renderGroups()}
</tbody>
</table>
</div>
</div>
);
}
renderGroups() {
const { me, t, classes } = this.props;
let groups = null;
if (me.groups.length > 0) {
groups = (
<tr>
<th>{t("profile.groups")}</th>
<td className={classes.spacing}>
<ul>
{me.groups.map(group => {
return <li>{group}</li>;
})}
</ul>
</td>
</tr>
);
}
return groups;
}
}
export default compose(
injectSheet(styles),
translate("commons")
)(ProfileInfo);

View File

@@ -0,0 +1,23 @@
// @flow
import React from "react";
import { withRouter } from "react-router-dom";
type Props = {
location: any,
children: any
}
class ScrollToTop extends React.Component<Props> {
componentDidUpdate(prevProps) {
if (this.props.location.pathname !== prevProps.location.pathname) {
window.scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
}
export default withRouter(ScrollToTop);