mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-14 09:25:43 +01:00
Merge with develop
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -8,11 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
- Add support for scroll anchors in url hash of diff page ([#1304](https://github.com/scm-manager/scm-manager/pull/1304))
|
||||
|
||||
## [2.4.1] - 2020-09-01
|
||||
### Added
|
||||
- Add "sonia.scm.restart-migration.wait" to set wait in milliseconds before restarting scm-server after migration ([#1308](https://github.com/scm-manager/scm-manager/pull/1308))
|
||||
|
||||
### Fixed
|
||||
- Fix detection of markdown files for files having content does not start with '#' ([#1306](https://github.com/scm-manager/scm-manager/pull/1306))
|
||||
- Fix broken markdown rendering ([#1303](https://github.com/scm-manager/scm-manager/pull/1303))
|
||||
- JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297))
|
||||
- Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298))
|
||||
- Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300))
|
||||
- Fix layout overflow on changesets with multiple tags ([#1314](https://github.com/scm-manager/scm-manager/pull/1314))
|
||||
- Make checkbox accessible from keyboard ([#1309](https://github.com/scm-manager/scm-manager/pull/1309))
|
||||
- Fix logging of large stacktrace for unknown language ([#1313](https://github.com/scm-manager/scm-manager/pull/1313))
|
||||
- Fix incorrect word breaking behaviour in markdown ([#1317](https://github.com/scm-manager/scm-manager/pull/1317))
|
||||
- Remove obsolete revision encoding on sources ([#1315](https://github.com/scm-manager/scm-manager/pull/1315))
|
||||
- Map generic JaxRS 'web application exceptions' to appropriate response instead of "internal server error" ([#1318](https://github.com/scm-manager/scm-manager/pull/1312))
|
||||
|
||||
|
||||
## [2.4.0] - 2020-08-14
|
||||
### Added
|
||||
@@ -284,3 +296,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[2.3.0]: https://www.scm-manager.org/download/2.3.0
|
||||
[2.3.1]: https://www.scm-manager.org/download/2.3.1
|
||||
[2.4.0]: https://www.scm-manager.org/download/2.4.0
|
||||
[2.4.1]: https://www.scm-manager.org/download/2.4.1
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "2.4.0"
|
||||
"version": "2.4.1"
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@ public class AuthenticationFilter extends HttpFilter {
|
||||
HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription());
|
||||
}
|
||||
|
||||
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
|
||||
throw tokenExpiredException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates all {@link WebTokenGenerator} and creates an
|
||||
* {@link AuthenticationToken} from the given request.
|
||||
@@ -211,7 +216,7 @@ public class AuthenticationFilter extends HttpFilter {
|
||||
processChain(request, response, chain, subject);
|
||||
} catch (TokenExpiredException ex) {
|
||||
// Rethrow to be caught by TokenExpiredFilter
|
||||
throw ex;
|
||||
handleTokenExpiredException(request, response, chain, ex);
|
||||
} catch (AuthenticationException ex) {
|
||||
logger.warn("authentication failed", ex);
|
||||
handleUnauthorized(request, response, chain);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
package sonia.scm.web.filter;
|
||||
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.TokenExpiredException;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.UserAgent;
|
||||
import sonia.scm.web.UserAgentParser;
|
||||
@@ -59,4 +60,15 @@ public class HttpProtocolServletAuthenticationFilterBase extends AuthenticationF
|
||||
HttpUtil.sendUnauthorized(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
|
||||
UserAgent userAgent = userAgentParser.parse(request);
|
||||
if (userAgent.isBrowser()) {
|
||||
// we can proceed the filter chain because the HttpProtocolServlet will render the ui if the client is a browser
|
||||
chain.doFilter(request, response);
|
||||
} else {
|
||||
super.handleTokenExpiredException(request, response, chain, tokenExpiredException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.TokenExpiredException;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.UserAgent;
|
||||
import sonia.scm.web.UserAgentParser;
|
||||
@@ -43,6 +44,7 @@ import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -93,4 +95,23 @@ class HttpProtocolServletAuthenticationFilterBaseTest {
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreTokenExpiredExceptionForBrowserCall() throws IOException, ServletException {
|
||||
when(userAgentParser.parse(request)).thenReturn(browser);
|
||||
|
||||
authenticationFilter.handleTokenExpiredException(request, response, filterChain, new TokenExpiredException("Nothing ever expired so much"));
|
||||
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRethrowTokenExpiredExceptionForApiCall() {
|
||||
when(userAgentParser.parse(request)).thenReturn(nonBrowser);
|
||||
|
||||
final TokenExpiredException tokenExpiredException = new TokenExpiredException("Nothing ever expired so much");
|
||||
|
||||
assertThrows(TokenExpiredException.class,
|
||||
() -> authenticationFilter.handleTokenExpiredException(request, response, filterChain, tokenExpiredException));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-git-plugin",
|
||||
"private": true,
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -20,6 +20,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.4.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-hg-plugin",
|
||||
"private": true,
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.4.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-legacy-plugin",
|
||||
"private": true,
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.tsx",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.4.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-svn-plugin",
|
||||
"private": true,
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.4.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/e2e-tests",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "End to end Tests for SCM-Manager",
|
||||
"main": "index.js",
|
||||
"author": "Eduard Heimbuch <eduard.heimbuch@cloudogu.com>",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { withI18next } from "storybook-addon-i18next";
|
||||
import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss";
|
||||
import React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import withRedux from "./withRedux";
|
||||
|
||||
|
||||
let i18n = i18next;
|
||||
@@ -70,4 +71,6 @@ addDecorator(
|
||||
})
|
||||
);
|
||||
|
||||
addDecorator(withRedux);
|
||||
|
||||
configure(require.context("../src", true, /\.stories\.tsx?$/), module);
|
||||
|
||||
41
scm-ui/ui-components/.storybook/withRedux.js
Normal file
41
scm-ui/ui-components/.storybook/withRedux.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {createStore} from "redux";
|
||||
import { Provider } from 'react-redux'
|
||||
|
||||
const reducer = (state, action) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
const withRedux = (storyFn) => {
|
||||
return React.createElement(Provider, {
|
||||
store: createStore(reducer, {}),
|
||||
children: storyFn()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export default withRedux;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-components",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "UI Components for SCM-Manager and its plugins",
|
||||
"main": "src/index.ts",
|
||||
"files": [
|
||||
|
||||
@@ -21,14 +21,24 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { ReactNode } from "react";
|
||||
import React, { ComponentType, ReactNode } from "react";
|
||||
import ErrorNotification from "./ErrorNotification";
|
||||
import { MissingLinkError } from "./errors";
|
||||
import { withContextPath } from "./urls";
|
||||
import { withRouter, RouteComponentProps } from "react-router-dom";
|
||||
import ErrorPage from "./ErrorPage";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { compose } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
type Props = {
|
||||
type ExportedProps = {
|
||||
fallback?: React.ComponentType<any>;
|
||||
children: ReactNode;
|
||||
loginLink?: string;
|
||||
};
|
||||
|
||||
type Props = WithTranslation & RouteComponentProps & ExportedProps;
|
||||
|
||||
type ErrorInfo = {
|
||||
componentStack: string;
|
||||
};
|
||||
@@ -44,16 +54,44 @@ class ErrorBoundary extends React.Component<Props, State> {
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Catch errors in any components below and re-render with error message
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
componentDidUpdate(prevProps: Readonly<Props>) {
|
||||
// we must reset the error if the url has changed
|
||||
if (this.state.error && prevProps.location !== this.props.location) {
|
||||
this.setState({ error: undefined, errorInfo: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.setState(
|
||||
{
|
||||
error,
|
||||
errorInfo
|
||||
},
|
||||
() => this.redirectToLogin(error)
|
||||
);
|
||||
}
|
||||
|
||||
redirectToLogin = (error: Error) => {
|
||||
const { loginLink } = this.props;
|
||||
if (error instanceof MissingLinkError) {
|
||||
if (loginLink) {
|
||||
window.location.assign(withContextPath("/login"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderError = () => {
|
||||
const { t } = this.props;
|
||||
const { error } = this.state;
|
||||
|
||||
let FallbackComponent = this.props.fallback;
|
||||
|
||||
if (error instanceof MissingLinkError) {
|
||||
return (
|
||||
<ErrorPage error={error} title={t("errorNotification.prefix")} subtitle={t("errorNotification.forbidden")} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!FallbackComponent) {
|
||||
FallbackComponent = ErrorNotification;
|
||||
}
|
||||
@@ -69,4 +107,17 @@ class ErrorBoundary extends React.Component<Props, State> {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
export default ErrorBoundary;
|
||||
|
||||
const mapStateToProps = (state: any) => {
|
||||
const loginLink = state.indexResources?.links?.login?.href;
|
||||
|
||||
return {
|
||||
loginLink
|
||||
};
|
||||
};
|
||||
|
||||
export default compose<ComponentType<ExportedProps>>(
|
||||
withRouter,
|
||||
withTranslation("commons"),
|
||||
connect(mapStateToProps)
|
||||
)(ErrorBoundary);
|
||||
|
||||
@@ -21,16 +21,26 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import React, { FC } from "react";
|
||||
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
|
||||
import { BackendError, ForbiddenError, UnauthorizedError } from "./errors";
|
||||
import Notification from "./Notification";
|
||||
import BackendErrorNotification from "./BackendErrorNotification";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { withContextPath } from "./urls";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
const LoginLink: FC = () => {
|
||||
const [t] = useTranslation("commons");
|
||||
const location = useLocation();
|
||||
const from = encodeURIComponent(location.pathname);
|
||||
|
||||
return <a href={withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
|
||||
};
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, error } = this.props;
|
||||
@@ -40,8 +50,7 @@ class ErrorNotification extends React.Component<Props> {
|
||||
} else if (error instanceof UnauthorizedError) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")}{" "}
|
||||
<a href="javascript:window.location.reload(true)">{t("errorNotification.loginLink")}</a>
|
||||
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")} <LoginLink />
|
||||
</Notification>
|
||||
);
|
||||
} else if (error instanceof ForbiddenError) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { Component } from "react";
|
||||
import { Route, Redirect, withRouter, RouteComponentProps, RouteProps } from "react-router-dom";
|
||||
import { Redirect, Route, RouteComponentProps, RouteProps, withRouter } from "react-router-dom";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
RouteProps & {
|
||||
@@ -30,7 +30,16 @@ type Props = RouteComponentProps &
|
||||
};
|
||||
|
||||
class ProtectedRoute extends Component<Props> {
|
||||
renderRoute = (Component: any, authenticated?: boolean) => {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: undefined
|
||||
};
|
||||
}
|
||||
|
||||
renderRoute = (Component: any) => {
|
||||
const { authenticated } = this.props;
|
||||
|
||||
return (routeProps: any) => {
|
||||
if (authenticated) {
|
||||
return <Component {...routeProps} />;
|
||||
@@ -50,8 +59,8 @@ class ProtectedRoute extends Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { component, authenticated, ...routeProps } = this.props;
|
||||
return <Route {...routeProps} render={this.renderRoute(component, authenticated)} />;
|
||||
const { component, ...routeProps } = this.props;
|
||||
return <Route {...routeProps} render={this.renderRoute(component)} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
type Props = {
|
||||
message: string;
|
||||
className?: string;
|
||||
location: string;
|
||||
multiline?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -37,9 +37,17 @@ class Tooltip extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, message, location, children } = this.props;
|
||||
const { className, message, location, multiline, children } = this.props;
|
||||
let classes = `tooltip has-tooltip-${location}`;
|
||||
if (multiline) {
|
||||
classes += " has-tooltip-multiline";
|
||||
}
|
||||
if (className) {
|
||||
classes += " " + className;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames("tooltip", "has-tooltip-" + location, className)} data-tooltip={message}>
|
||||
<span className={classes} data-tooltip={message}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -44140,14 +44140,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-square has-text-black far"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Not checked
|
||||
</label>
|
||||
</div>
|
||||
@@ -44158,14 +44164,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-check-square has-text-link fa"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Checked
|
||||
</label>
|
||||
</div>
|
||||
@@ -44176,14 +44188,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-minus-square has-text-link far"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Indeterminate
|
||||
</label>
|
||||
</div>
|
||||
@@ -44201,15 +44219,21 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
disabled={true}
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-check-square has-text-grey-light fa"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Checked but disabled
|
||||
</label>
|
||||
</div>
|
||||
@@ -44227,14 +44251,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-square has-text-black far"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Classic helpText
|
||||
<span
|
||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
|
||||
@@ -44253,14 +44283,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-check-square has-text-link fa"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Long helpText
|
||||
<span
|
||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
|
||||
@@ -47632,14 +47668,20 @@ exports[`Storyshots Modal|Modal With long tooltips 1`] = `
|
||||
<div
|
||||
className="control"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<label
|
||||
className="checkbox"
|
||||
>
|
||||
<span
|
||||
className="gwt-Anchor"
|
||||
tabIndex={0}
|
||||
>
|
||||
<i
|
||||
className="is-outlined fa-check-square has-text-link fa"
|
||||
/>
|
||||
|
||||
</span>
|
||||
Checkbox
|
||||
<span
|
||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
|
||||
|
||||
@@ -125,23 +125,15 @@ const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
|
||||
|
||||
function handleFailure(response: Response) {
|
||||
if (!response.ok) {
|
||||
if (isBackendError(response)) {
|
||||
return response.json().then((content: BackendErrorContent) => {
|
||||
if (content.errorCode === TOKEN_EXPIRED_ERROR_CODE) {
|
||||
window.location.replace(`${contextPath}/login`);
|
||||
// Throw error because if redirect is not instantaneous, we want to display something senseful
|
||||
throw new UnauthorizedError("Unauthorized", 401);
|
||||
} else {
|
||||
throw createBackendError(content, response.status);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
throw new UnauthorizedError("Unauthorized", 401);
|
||||
} else if (response.status === 403) {
|
||||
throw new ForbiddenError("Forbidden", 403);
|
||||
}
|
||||
|
||||
} else if (isBackendError(response)) {
|
||||
return response.json().then((content: BackendErrorContent) => {
|
||||
throw createBackendError(content, response.status);
|
||||
});
|
||||
} else {
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
}
|
||||
@@ -163,24 +155,35 @@ export function createUrlWithIdentifiers(url: string): string {
|
||||
return createUrl(url) + "?X-SCM-Client=WUI&X-SCM-Session-ID=" + sessionId;
|
||||
}
|
||||
|
||||
type ErrorListener = (error: Error) => void;
|
||||
|
||||
class ApiClient {
|
||||
get(url: string): Promise<Response> {
|
||||
return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure);
|
||||
}
|
||||
errorListeners: ErrorListener[] = [];
|
||||
|
||||
post(url: string, payload?: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
|
||||
get = (url: string): Promise<Response> => {
|
||||
return fetch(createUrl(url), applyFetchOptions({}))
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
post = (
|
||||
url: string,
|
||||
payload?: any,
|
||||
contentType = "application/json",
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
) => {
|
||||
return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload);
|
||||
}
|
||||
};
|
||||
|
||||
postText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) {
|
||||
postText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
|
||||
return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload);
|
||||
}
|
||||
};
|
||||
|
||||
putText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) {
|
||||
putText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
|
||||
return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload);
|
||||
}
|
||||
};
|
||||
|
||||
postBinary(url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) {
|
||||
postBinary = (url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) => {
|
||||
const formData = new FormData();
|
||||
fileAppender(formData);
|
||||
|
||||
@@ -190,35 +193,39 @@ class ApiClient {
|
||||
headers: additionalHeaders
|
||||
};
|
||||
return this.httpRequestWithBinaryBody(options, url);
|
||||
}
|
||||
};
|
||||
|
||||
put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
|
||||
return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload);
|
||||
}
|
||||
|
||||
head(url: string) {
|
||||
head = (url: string) => {
|
||||
let options: RequestInit = {
|
||||
method: "HEAD"
|
||||
};
|
||||
options = applyFetchOptions(options);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
return fetch(createUrl(url), options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
delete(url: string): Promise<Response> {
|
||||
delete = (url: string): Promise<Response> => {
|
||||
let options: RequestInit = {
|
||||
method: "DELETE"
|
||||
};
|
||||
options = applyFetchOptions(options);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
return fetch(createUrl(url), options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
httpRequestWithJSONBody(
|
||||
httpRequestWithJSONBody = (
|
||||
method: string,
|
||||
url: string,
|
||||
contentType: string,
|
||||
additionalHeaders: Record<string, string>,
|
||||
payload?: any
|
||||
): Promise<Response> {
|
||||
): Promise<Response> => {
|
||||
const options: RequestInit = {
|
||||
method: method,
|
||||
headers: additionalHeaders
|
||||
@@ -227,23 +234,23 @@ class ApiClient {
|
||||
options.body = JSON.stringify(payload);
|
||||
}
|
||||
return this.httpRequestWithBinaryBody(options, url, contentType);
|
||||
}
|
||||
};
|
||||
|
||||
httpRequestWithTextBody(
|
||||
httpRequestWithTextBody = (
|
||||
method: string,
|
||||
url: string,
|
||||
additionalHeaders: Record<string, string> = {},
|
||||
payload: string
|
||||
) {
|
||||
) => {
|
||||
const options: RequestInit = {
|
||||
method: method,
|
||||
headers: additionalHeaders
|
||||
};
|
||||
options.body = payload;
|
||||
return this.httpRequestWithBinaryBody(options, url, "text/plain");
|
||||
}
|
||||
};
|
||||
|
||||
httpRequestWithBinaryBody(options: RequestInit, url: string, contentType?: string) {
|
||||
httpRequestWithBinaryBody = (options: RequestInit, url: string, contentType?: string) => {
|
||||
options = applyFetchOptions(options);
|
||||
if (contentType) {
|
||||
if (!options.headers) {
|
||||
@@ -253,8 +260,10 @@ class ApiClient {
|
||||
options.headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
return fetch(createUrl(url), options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
subscribe(url: string, argument: SubscriptionArgument): Cancel {
|
||||
const es = new EventSource(createUrlWithIdentifiers(url), {
|
||||
@@ -284,6 +293,15 @@ class ApiClient {
|
||||
|
||||
return () => es.close();
|
||||
}
|
||||
|
||||
onError = (errorListener: ErrorListener) => {
|
||||
this.errorListeners.push(errorListener);
|
||||
};
|
||||
|
||||
private notifyAndRethrow = (error: Error): never => {
|
||||
this.errorListeners.forEach(errorListener => errorListener(error));
|
||||
throw error;
|
||||
};
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -89,6 +89,10 @@ export class ConflictError extends BackendError {
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingLinkError extends Error {
|
||||
name = "MissingLinkError";
|
||||
}
|
||||
|
||||
export function createBackendError(content: BackendErrorContent, statusCode: number) {
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
|
||||
@@ -45,6 +45,13 @@ export default class Checkbox extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown = (event: React.KeyboardEvent) => {
|
||||
const SPACE = 32;
|
||||
if (event.keyCode === SPACE) {
|
||||
this.onCheckboxChange();
|
||||
}
|
||||
};
|
||||
|
||||
renderHelp = () => {
|
||||
const { title, helpText } = this.props;
|
||||
if (helpText && !title) {
|
||||
@@ -64,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
|
||||
return (
|
||||
<div className="field">
|
||||
{this.renderLabelWithHelp()}
|
||||
<div className="control" onClick={this.onCheckboxChange}>
|
||||
<div className="control" onClick={this.onCheckboxChange} onKeyDown={this.onKeyDown}>
|
||||
{/*
|
||||
we have to ignore the next line,
|
||||
because jsx label does not the custom disabled attribute
|
||||
|
||||
@@ -58,10 +58,13 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label,
|
||||
color = "black";
|
||||
}
|
||||
|
||||
// We need a tabIndex to make the checkbox accessible from keyboard.
|
||||
// We also add the gwt-Anchor css class to support the key-jump browser extension
|
||||
// https://github.com/KennethSundqvist/key-jump-chrome-extension/blob/master/src/content.js#L365
|
||||
return (
|
||||
<>
|
||||
<span tabIndex={0} className="gwt-Anchor">
|
||||
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import PrimaryNavigationLink from "./PrimaryNavigationLink";
|
||||
import { Links } from "@scm-manager/ui-types";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import {withContextPath} from "../urls";
|
||||
import { withRouter, RouteComponentProps } from "react-router-dom";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
type Props = RouteComponentProps & WithTranslation & {
|
||||
links: Links;
|
||||
};
|
||||
|
||||
@@ -64,11 +66,17 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
};
|
||||
|
||||
appendLogin = (navigationItems: ReactNode[], append: Appender) => {
|
||||
const { t, links } = this.props;
|
||||
const { t, links, location } = this.props;
|
||||
|
||||
const from = location.pathname;
|
||||
const loginPath = "/login";
|
||||
const to = `${loginPath}?from=${encodeURIComponent(from)}`;
|
||||
|
||||
const props = {
|
||||
links,
|
||||
label: t("primary-navigation.login")
|
||||
label: t("primary-navigation.login"),
|
||||
loginUrl: withContextPath(loginPath),
|
||||
from
|
||||
};
|
||||
|
||||
if (binder.hasExtension("primary-navigation.login", props)) {
|
||||
@@ -76,7 +84,7 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
<ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} />
|
||||
);
|
||||
} else {
|
||||
append("/login", "/login", "primary-navigation.login", "login");
|
||||
append(to, "/login", "primary-navigation.login", "login");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,4 +136,4 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation("commons")(PrimaryNavigation);
|
||||
export default withTranslation("commons")(withRouter(PrimaryNavigation));
|
||||
|
||||
@@ -36,7 +36,7 @@ class ChangesetTagsCollapsed extends React.Component<Props> {
|
||||
const { tags, t } = this.props;
|
||||
const message = tags.map(tag => tag.name).join(", ");
|
||||
return (
|
||||
<Tooltip location="top" message={message}>
|
||||
<Tooltip location="top" message={message} multiline={true}>
|
||||
<ChangesetTagBase icon="tags" label={tags.length + " " + t("changeset.tags")} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-plugins",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ui-plugins": "./bin/ui-plugins.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-components": "^2.4.0",
|
||||
"@scm-manager/ui-components": "^2.4.1",
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"classnames": "^2.2.6",
|
||||
"query-string": "^5.0.1",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@scm-manager/jest-preset": "^2.1.0",
|
||||
"@scm-manager/prettier-config": "^2.1.0",
|
||||
"@scm-manager/tsconfig": "^2.1.0",
|
||||
"@scm-manager/ui-scripts": "^2.1.0",
|
||||
"@scm-manager/ui-scripts": "^2.4.1",
|
||||
"@scm-manager/ui-tests": "^2.1.0",
|
||||
"@scm-manager/ui-types": "^2.4.0",
|
||||
"@types/classnames": "^2.2.9",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-scripts",
|
||||
"version": "2.1.0",
|
||||
"version": "2.4.1",
|
||||
"description": "Build scripts for SCM-Manager",
|
||||
"main": "src/index.js",
|
||||
"author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>",
|
||||
@@ -14,7 +14,7 @@
|
||||
"babel-loader": "^8.0.6",
|
||||
"css-loader": "^3.2.0",
|
||||
"file-loader": "^4.2.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"mini-css-extract-plugin": "^0.10.0",
|
||||
"mustache": "^3.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"react-refresh": "^0.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-styles",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "Styles for SCM-Manager",
|
||||
"main": "src/scm.scss",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,11 +15,11 @@ $family-monospace: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro
|
||||
}
|
||||
|
||||
.is-word-break {
|
||||
-webkit-hyphens: auto;
|
||||
-moz-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
hyphens: auto;
|
||||
word-break: break-all;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.has-rounded-border {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-webapp",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-components": "^2.4.0",
|
||||
"@scm-manager/ui-components": "^2.4.1",
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"classnames": "^2.2.5",
|
||||
"history": "^4.10.1",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@types/react-dom": "^16.9.2",
|
||||
"@types/react-redux": "5.0.7",
|
||||
"@types/react-router-dom": "^5.1.0",
|
||||
"@types/redux-logger": "^3.0.8",
|
||||
"@types/styled-components": "^5.1.0",
|
||||
"@types/systemjs": "^0.20.6",
|
||||
"fetch-mock": "^7.5.1",
|
||||
|
||||
@@ -29,12 +29,13 @@ import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { Links } from "@scm-manager/ui-types";
|
||||
import {
|
||||
CustomQueryFlexWrappedColumns,
|
||||
NavLink,
|
||||
Page,
|
||||
CustomQueryFlexWrappedColumns,
|
||||
PrimaryContentColumn,
|
||||
SecondaryNavigationColumn,
|
||||
SecondaryNavigation,
|
||||
SecondaryNavigationColumn,
|
||||
StateMenuContextProvider,
|
||||
SubNavigation
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getAvailablePluginsLink, getInstalledPluginsLink, getLinks } from "../../modules/indexResource";
|
||||
@@ -44,7 +45,6 @@ import GlobalConfig from "./GlobalConfig";
|
||||
import RepositoryRoles from "../roles/containers/RepositoryRoles";
|
||||
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
|
||||
import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
|
||||
import { StateMenuContextProvider } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
WithTranslation & {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Config, NamespaceStrategies } from "@scm-manager/ui-types";
|
||||
import { ErrorNotification, Loading, Title } from "@scm-manager/ui-components";
|
||||
import { getConfigLink } from "../../modules/indexResource";
|
||||
import { mustGetConfigLink } from "../../modules/indexResource";
|
||||
import {
|
||||
fetchConfig,
|
||||
getConfig,
|
||||
@@ -186,7 +186,7 @@ const mapStateToProps = (state: any) => {
|
||||
|
||||
const config = getConfig(state);
|
||||
const configUpdatePermission = getConfigUpdatePermission(state);
|
||||
const configLink = getConfigLink(state);
|
||||
const configLink = mustGetConfigLink(state);
|
||||
const namespaceStrategies = getNamespaceStrategies(state);
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,7 +25,7 @@ import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { compose } from "redux";
|
||||
import { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
|
||||
import { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -45,16 +45,15 @@ import {
|
||||
} from "../modules/plugins";
|
||||
import PluginsList from "../components/PluginList";
|
||||
import {
|
||||
getAvailablePluginsLink,
|
||||
getInstalledPluginsLink,
|
||||
getPendingPluginsLink
|
||||
getPendingPluginsLink,
|
||||
mustGetAvailablePluginsLink,
|
||||
mustGetInstalledPluginsLink
|
||||
} from "../../../modules/indexResource";
|
||||
import PluginTopActions from "../components/PluginTopActions";
|
||||
import PluginBottomActions from "../components/PluginBottomActions";
|
||||
import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
|
||||
import CancelPendingActionModal from "../components/CancelPendingActionModal";
|
||||
import UpdateAllActionModal from "../components/UpdateAllActionModal";
|
||||
import { Plugin } from "@scm-manager/ui-types";
|
||||
import ShowPendingModal from "../components/ShowPendingModal";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
@@ -319,8 +318,8 @@ const mapStateToProps = (state: any) => {
|
||||
const collection = getPluginCollection(state);
|
||||
const loading = isFetchPluginsPending(state);
|
||||
const error = getFetchPluginsFailure(state);
|
||||
const availablePluginsLink = getAvailablePluginsLink(state);
|
||||
const installedPluginsLink = getInstalledPluginsLink(state);
|
||||
const availablePluginsLink = mustGetAvailablePluginsLink(state);
|
||||
const installedPluginsLink = mustGetInstalledPluginsLink(state);
|
||||
const pendingPluginsLink = getPendingPluginsLink(state);
|
||||
const pendingPlugins = getPendingPlugins(state);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { History } from "history";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { RepositoryRole } from "@scm-manager/ui-types";
|
||||
import { ErrorPage, Loading, Title } from "@scm-manager/ui-components";
|
||||
import { getRepositoryRolesLink } from "../../../modules/indexResource";
|
||||
import { mustGetRepositoryRolesLink } from "../../../modules/indexResource";
|
||||
import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending } from "../modules/roles";
|
||||
import PermissionRoleDetail from "../components/PermissionRoleDetails";
|
||||
import EditRepositoryRole from "./EditRepositoryRole";
|
||||
@@ -107,7 +107,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const role = getRoleByName(state, roleName);
|
||||
const loading = isFetchRolePending(state, roleName);
|
||||
const error = getFetchRoleFailure(state, roleName);
|
||||
const repositoryRolesLink = getRepositoryRolesLink(state);
|
||||
const repositoryRolesLink = mustGetRepositoryRolesLink(state);
|
||||
return {
|
||||
repositoryRolesLink,
|
||||
roleName,
|
||||
|
||||
@@ -64,6 +64,15 @@ class Index extends Component<Props, State> {
|
||||
this.props.fetchIndexResources();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { indexResources, loading, error } = this.props;
|
||||
const { pluginsLoaded } = this.state;
|
||||
if (!indexResources && !loading && !error && pluginsLoaded) {
|
||||
this.props.fetchIndexResources();
|
||||
this.setState({ pluginsLoaded: false });
|
||||
}
|
||||
}
|
||||
|
||||
pluginLoaderCallback = () => {
|
||||
this.setState({
|
||||
pluginsLoaded: true
|
||||
|
||||
62
scm-ui/ui-webapp/src/containers/Login.test.ts
Normal file
62
scm-ui/ui-webapp/src/containers/Login.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { from } from "./Login";
|
||||
|
||||
describe("from tests", () => {
|
||||
it("should use default location", () => {
|
||||
const path = from("", {});
|
||||
expect(path).toBe("/");
|
||||
});
|
||||
|
||||
it("should use default location without params", () => {
|
||||
const path = from();
|
||||
expect(path).toBe("/");
|
||||
});
|
||||
|
||||
it("should use default location with null params", () => {
|
||||
const path = from("", null);
|
||||
expect(path).toBe("/");
|
||||
});
|
||||
|
||||
it("should use location from query parameter", () => {
|
||||
const path = from("from=/repos", {});
|
||||
expect(path).toBe("/repos");
|
||||
});
|
||||
|
||||
it("should use location from state", () => {
|
||||
const path = from("", { from: "/users" });
|
||||
expect(path).toBe("/users");
|
||||
});
|
||||
|
||||
it("should prefer location from query parameter", () => {
|
||||
const path = from("from=/groups", { from: "/users" });
|
||||
expect(path).toBe("/groups");
|
||||
});
|
||||
|
||||
it("should decode query param", () => {
|
||||
const path = from(`from=${encodeURIComponent("/admin/plugins/installed")}`);
|
||||
expect(path).toBe("/admin/plugins/installed");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { getLoginFailure, getMe, isAnonymous, isLoginPending, login } from "../m
|
||||
import { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
|
||||
import LoginInfo from "../components/LoginInfo";
|
||||
import { Me } from "@scm-manager/ui-types";
|
||||
import { parse } from "query-string";
|
||||
|
||||
type Props = RouteComponentProps & {
|
||||
authenticated: boolean;
|
||||
@@ -47,6 +48,18 @@ const HeroSection = styled.section`
|
||||
padding-top: 2em;
|
||||
`;
|
||||
|
||||
interface FromObject {
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @visibleForTesting
|
||||
*/
|
||||
export const from = (queryString?: string, stateParams?: FromObject | null): string => {
|
||||
const queryParams = parse(queryString || "");
|
||||
return queryParams?.from || stateParams?.from || "/";
|
||||
};
|
||||
|
||||
class Login extends React.Component<Props> {
|
||||
handleLogin = (username: string, password: string): void => {
|
||||
const { link, login } = this.props;
|
||||
@@ -54,12 +67,8 @@ class Login extends React.Component<Props> {
|
||||
};
|
||||
|
||||
renderRedirect = () => {
|
||||
const { from } = this.props.location.state || {
|
||||
from: {
|
||||
pathname: "/"
|
||||
}
|
||||
};
|
||||
return <Redirect to={from} />;
|
||||
const to = from(window.location.search, this.props.location.state);
|
||||
return <Redirect to={to} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -31,7 +31,7 @@ import Users from "../users/containers/Users";
|
||||
import Login from "../containers/Login";
|
||||
import Logout from "../containers/Logout";
|
||||
|
||||
import { ProtectedRoute } from "@scm-manager/ui-components";
|
||||
import { ProtectedRoute, ErrorBoundary } from "@scm-manager/ui-components";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
import CreateUser from "../users/containers/CreateUser";
|
||||
@@ -68,6 +68,7 @@ class Main extends React.Component<Props> {
|
||||
url = "/login";
|
||||
}
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="main">
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={url} />
|
||||
@@ -80,13 +81,13 @@ class Main extends React.Component<Props> {
|
||||
<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 path="/users/create" component={CreateUser} authenticated={authenticated} />
|
||||
<ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} />
|
||||
<ProtectedRoute authenticated={authenticated} path="/user/:name" component={SingleUser} />
|
||||
<ProtectedRoute path="/user/:name" component={SingleUser} authenticated={authenticated} />
|
||||
<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 path="/group/:name" component={SingleGroup} authenticated={authenticated} />
|
||||
<ProtectedRoute path="/groups/create" component={CreateGroup} authenticated={authenticated} />
|
||||
<ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} />
|
||||
<ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} />
|
||||
<ProtectedRoute path="/me" component={Profile} authenticated={authenticated} />
|
||||
@@ -94,13 +95,14 @@ class Main extends React.Component<Props> {
|
||||
name="main.route"
|
||||
renderAll={true}
|
||||
props={{
|
||||
authenticated,
|
||||
me,
|
||||
links
|
||||
links,
|
||||
authenticated
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
|
||||
import thunk from "redux-thunk";
|
||||
import logger from "redux-logger";
|
||||
import { applyMiddleware, combineReducers, compose, createStore } from "redux";
|
||||
import { AnyAction, applyMiddleware, combineReducers, compose, createStore } from "redux";
|
||||
import users from "./users/modules/users";
|
||||
import repos from "./repos/modules/repos";
|
||||
import repositoryTypes from "./repos/modules/repositoryTypes";
|
||||
import changesets from "./repos/modules/changesets";
|
||||
import sources from "./repos/sources/modules/sources";
|
||||
import groups from "./groups/modules/groups";
|
||||
import auth from "./modules/auth";
|
||||
import auth, { isAuthenticated } from "./modules/auth";
|
||||
import pending from "./modules/pending";
|
||||
import failure from "./modules/failure";
|
||||
import permissions from "./repos/permissions/modules/permissions";
|
||||
@@ -40,13 +40,16 @@ import roles from "./admin/roles/modules/roles";
|
||||
import namespaceStrategies from "./admin/modules/namespaceStrategies";
|
||||
import indexResources from "./modules/indexResource";
|
||||
import plugins from "./admin/plugins/modules/plugins";
|
||||
|
||||
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
import branches from "./repos/branches/modules/branches";
|
||||
|
||||
const EMPTY_STATE = {} as any;
|
||||
|
||||
function createReduxStore() {
|
||||
// @ts-ignore __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ is defined by react dev tools
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const reducer = combineReducers({
|
||||
const appReducer = combineReducers({
|
||||
pending,
|
||||
failure,
|
||||
indexResources,
|
||||
@@ -65,7 +68,39 @@ function createReduxStore() {
|
||||
plugins
|
||||
});
|
||||
|
||||
return createStore(reducer, composeEnhancers(applyMiddleware(thunk, logger)));
|
||||
// We assume that an UnauthorizedError means that the access token is expired.
|
||||
// If the token is expired we want to show an error with the login link.
|
||||
// This error should be displayed with the state (e.g. navigation) of the previous logged in user.
|
||||
// But if the user navigates away, we want to reset the state to an anonymous one.
|
||||
|
||||
const reducer = (state: any, action: AnyAction) => {
|
||||
// Reset the state if the token is expired and a new action is dispatched (e.g. navigation).
|
||||
// We exclude failures, because the fetch which had triggered the unauthorized error
|
||||
// will likely end with an failure action.
|
||||
if (state.tokenExpired && !action.type.includes("FAILURE")) {
|
||||
// reset state by passing an empty state down to the app reducer
|
||||
// we do not use the captured action, because the data is derived from the old state
|
||||
return appReducer(EMPTY_STATE, { type: "_" });
|
||||
}
|
||||
|
||||
// If the user is authenticated and response is an unauthorized error,
|
||||
// we assume that the token is expired.
|
||||
if (action.type === "API_CLIENT_UNAUTHORIZED" && isAuthenticated(state)) {
|
||||
return { ...state, tokenExpired: true };
|
||||
}
|
||||
|
||||
// Keep the tokenExpired after calling appReducer,
|
||||
// this is required because the appReducer would remove any unknown property.
|
||||
return { ...appReducer(state, action), tokenExpired: state.tokenExpired };
|
||||
};
|
||||
|
||||
const store = createStore(reducer, EMPTY_STATE, composeEnhancers(applyMiddleware(thunk, logger)));
|
||||
apiClient.onError(error => {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
store.dispatch({ type: "API_CLIENT_UNAUTHORIZED", error });
|
||||
}
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
export default createReduxStore;
|
||||
|
||||
@@ -27,11 +27,10 @@ import { compose } from "redux";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { History } from "history";
|
||||
import { DisplayedUser, Group } from "@scm-manager/ui-types";
|
||||
import { Page } from "@scm-manager/ui-components";
|
||||
import { getGroupsLink, getUserAutoCompleteLink } from "../../modules/indexResource";
|
||||
import { apiClient, Page } from "@scm-manager/ui-components";
|
||||
import { getUserAutoCompleteLink, mustGetGroupsLink } from "../../modules/indexResource";
|
||||
import { createGroup, createGroupReset, getCreateGroupFailure, isCreateGroupPending } from "../modules/groups";
|
||||
import GroupForm from "../components/GroupForm";
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
createGroup: (link: string, group: Group, callback?: () => void) => void;
|
||||
@@ -97,7 +96,7 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
const mapStateToProps = (state: any) => {
|
||||
const loading = isCreateGroupPending(state);
|
||||
const error = getCreateGroupFailure(state);
|
||||
const createLink = getGroupsLink(state);
|
||||
const createLink = mustGetGroupsLink(state);
|
||||
const autocompleteLink = getUserAutoCompleteLink(state);
|
||||
return {
|
||||
createLink,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
PageActions,
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getGroupsLink } from "../../modules/indexResource";
|
||||
import { mustGetGroupsLink } from "../../modules/indexResource";
|
||||
import {
|
||||
fetchGroupsByPage,
|
||||
getFetchGroupsFailure,
|
||||
@@ -128,7 +128,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const page = urls.getPageFromMatch(match);
|
||||
const canAddGroups = isPermittedToCreateGroups(state);
|
||||
const list = selectListAsCollection(state);
|
||||
const groupLink = getGroupsLink(state);
|
||||
const groupLink = mustGetGroupsLink(state);
|
||||
|
||||
return {
|
||||
groups,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
SubNavigation,
|
||||
StateMenuContextProvider
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getGroupsLink } from "../../modules/indexResource";
|
||||
import { getGroupsLink, mustGetGroupsLink } from "../../modules/indexResource";
|
||||
import { fetchGroupByName, getFetchGroupFailure, getGroupByName, isFetchGroupPending } from "../modules/groups";
|
||||
import { Details } from "./../components/table";
|
||||
import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks";
|
||||
@@ -138,7 +138,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const group = getGroupByName(state, name);
|
||||
const loading = isFetchGroupPending(state, name);
|
||||
const error = getFetchGroupFailure(state, name);
|
||||
const groupLink = getGroupsLink(state);
|
||||
const groupLink = mustGetGroupsLink(state);
|
||||
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -117,7 +117,7 @@ const indexResourcesAuthenticated = {
|
||||
|
||||
describe("index resource", () => {
|
||||
describe("fetch index resource", () => {
|
||||
const index_url = "/api/v2/";
|
||||
const indexUrl = "/api/v2/";
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,7 +126,7 @@ describe("index resource", () => {
|
||||
});
|
||||
|
||||
it("should successfully fetch index resources when unauthenticated", () => {
|
||||
fetchMock.getOnce(index_url, indexResourcesUnauthenticated);
|
||||
fetchMock.getOnce(indexUrl, indexResourcesUnauthenticated);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
@@ -145,7 +145,7 @@ describe("index resource", () => {
|
||||
});
|
||||
|
||||
it("should successfully fetch index resources when authenticated", () => {
|
||||
fetchMock.getOnce(index_url, indexResourcesAuthenticated);
|
||||
fetchMock.getOnce(indexUrl, indexResourcesAuthenticated);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
@@ -164,7 +164,7 @@ describe("index resource", () => {
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => {
|
||||
fetchMock.getOnce(index_url, {
|
||||
fetchMock.getOnce(indexUrl, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
@@ -176,6 +176,50 @@ describe("index resource", () => {
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry to fetch index resource on unauthorized error", () => {
|
||||
fetchMock.getOnce(indexUrl, { status: 401 }).getOnce(indexUrl, indexResourcesAuthenticated, {
|
||||
overwriteRoutes: false
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: FETCH_INDEXRESOURCES_PENDING
|
||||
},
|
||||
{
|
||||
type: FETCH_INDEXRESOURCES_SUCCESS,
|
||||
payload: indexResourcesAuthenticated
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchIndexResources()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should only retry to fetch index resource on unauthorized error once", () => {
|
||||
fetchMock
|
||||
.getOnce(indexUrl, { status: 401 })
|
||||
.getOnce(
|
||||
indexUrl,
|
||||
{ status: 401 },
|
||||
{
|
||||
overwriteRoutes: false
|
||||
}
|
||||
)
|
||||
.getOnce(indexUrl, indexResourcesAuthenticated, {
|
||||
overwriteRoutes: false
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchIndexResources()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("index resources reducer", () => {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
import { apiClient, MissingLinkError, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
import { Action, IndexResources, Link } from "@scm-manager/ui-types";
|
||||
import { isPending } from "./pending";
|
||||
import { getFailure } from "./failure";
|
||||
@@ -46,14 +46,27 @@ export const callFetchIndexResources = (): Promise<IndexResources> => {
|
||||
|
||||
export function fetchIndexResources() {
|
||||
return function(dispatch: any) {
|
||||
/*
|
||||
* The index resource returns a 401 if the access token expired.
|
||||
* This only happens once because the error response automatically invalidates the cookie.
|
||||
* In this event, we have to try the request once again.
|
||||
*/
|
||||
let shouldRetry = true;
|
||||
dispatch(fetchIndexResourcesPending());
|
||||
return callFetchIndexResources()
|
||||
const run = (): Promise<any> =>
|
||||
callFetchIndexResources()
|
||||
.then(resources => {
|
||||
dispatch(fetchIndexResourcesSuccess(resources));
|
||||
})
|
||||
.catch(err => {
|
||||
if (err instanceof UnauthorizedError && shouldRetry) {
|
||||
shouldRetry = false;
|
||||
return run();
|
||||
} else {
|
||||
dispatch(fetchIndexResourcesFailure(err));
|
||||
}
|
||||
});
|
||||
return run();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +136,14 @@ export function getLink(state: object, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mustGetLink(state: object, name: string) {
|
||||
const link = getLink(state, name);
|
||||
if (link) {
|
||||
return link;
|
||||
}
|
||||
throw new MissingLinkError(`No link in state for link name: '${name}'`);
|
||||
}
|
||||
|
||||
export function getLinkCollection(state: object, name: string): Link[] {
|
||||
// @ts-ignore Right types not available
|
||||
if (state.indexResources.links && state.indexResources.links[name]) {
|
||||
@@ -145,10 +166,18 @@ export function getAvailablePluginsLink(state: object) {
|
||||
return getLink(state, "availablePlugins");
|
||||
}
|
||||
|
||||
export function mustGetAvailablePluginsLink(state: object) {
|
||||
return mustGetLink(state, "availablePlugins");
|
||||
}
|
||||
|
||||
export function getInstalledPluginsLink(state: object) {
|
||||
return getLink(state, "installedPlugins");
|
||||
}
|
||||
|
||||
export function mustGetInstalledPluginsLink(state: object) {
|
||||
return mustGetLink(state, "installedPlugins");
|
||||
}
|
||||
|
||||
export function getPendingPluginsLink(state: object) {
|
||||
return getLink(state, "pendingPlugins");
|
||||
}
|
||||
@@ -169,10 +198,18 @@ export function getUsersLink(state: object) {
|
||||
return getLink(state, "users");
|
||||
}
|
||||
|
||||
export function mustGetUsersLink(state: object) {
|
||||
return mustGetLink(state, "users");
|
||||
}
|
||||
|
||||
export function getRepositoryRolesLink(state: object) {
|
||||
return getLink(state, "repositoryRoles");
|
||||
}
|
||||
|
||||
export function mustGetRepositoryRolesLink(state: object) {
|
||||
return mustGetLink(state, "repositoryRoles");
|
||||
}
|
||||
|
||||
export function getRepositoryVerbsLink(state: object) {
|
||||
return getLink(state, "repositoryVerbs");
|
||||
}
|
||||
@@ -181,10 +218,18 @@ export function getGroupsLink(state: object) {
|
||||
return getLink(state, "groups");
|
||||
}
|
||||
|
||||
export function mustGetGroupsLink(state: object) {
|
||||
return mustGetLink(state, "groups");
|
||||
}
|
||||
|
||||
export function getConfigLink(state: object) {
|
||||
return getLink(state, "config");
|
||||
}
|
||||
|
||||
export function mustGetConfigLink(state: object) {
|
||||
return mustGetLink(state, "config");
|
||||
}
|
||||
|
||||
export function getRepositoriesLink(state: object) {
|
||||
return getLink(state, "repositories");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { Changeset, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
|
||||
import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types";
|
||||
import {
|
||||
AvatarImage,
|
||||
AvatarWrapper,
|
||||
@@ -36,14 +36,15 @@ import {
|
||||
ChangesetDiff,
|
||||
ChangesetId,
|
||||
changesets,
|
||||
ChangesetTag,
|
||||
ChangesetTags,
|
||||
DateFromNow,
|
||||
FileControlFactory,
|
||||
Icon,
|
||||
Level
|
||||
Level,
|
||||
SignatureIcon
|
||||
} from "@scm-manager/ui-components";
|
||||
import ContributorTable from "./ContributorTable";
|
||||
import { Link as ReactLink } from "react-router-dom";
|
||||
import { FileControlFactory, SignatureIcon } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
changeset: Changeset;
|
||||
@@ -59,16 +60,6 @@ const RightMarginP = styled.p`
|
||||
margin-right: 1em;
|
||||
`;
|
||||
|
||||
const TagsWrapper = styled.div`
|
||||
& .tag {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const SignedIcon = styled(SignatureIcon)`
|
||||
padding-left: 1rem;
|
||||
`;
|
||||
|
||||
const BottomMarginLevel = styled(Level)`
|
||||
margin-bottom: 1rem !important;
|
||||
`;
|
||||
@@ -224,7 +215,9 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
)}
|
||||
</ChangesetSummary>
|
||||
</div>
|
||||
<div className="media-right">{this.renderTags()}</div>
|
||||
<div className="media-right">
|
||||
<ChangesetTags changeset={changeset} />
|
||||
</div>
|
||||
</article>
|
||||
<p>
|
||||
{description.message.split("\n").map((item, key) => {
|
||||
@@ -264,24 +257,6 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
getTags = () => {
|
||||
return this.props.changeset._embedded.tags || [];
|
||||
};
|
||||
|
||||
renderTags = () => {
|
||||
const tags = this.getTags();
|
||||
if (tags.length > 0) {
|
||||
return (
|
||||
<TagsWrapper className="level-item">
|
||||
{tags.map((tag: Tag) => {
|
||||
return <ChangesetTag key={tag.name} tag={tag} />;
|
||||
})}
|
||||
</TagsWrapper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
collapseDiffs = () => {
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
|
||||
@@ -87,7 +87,7 @@ class SourcesView extends React.Component<Props, State> {
|
||||
const basePath = this.createBasePath();
|
||||
if (contentType.startsWith("image/")) {
|
||||
return <ImageViewer file={file} />;
|
||||
} else if (contentType.includes("markdown")) {
|
||||
} else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) {
|
||||
return <SwitchableMarkdownViewer file={file} basePath={basePath} />;
|
||||
} else if (language) {
|
||||
return <SourcecodeViewer file={file} language={language} />;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { History } from "history";
|
||||
import { User } from "@scm-manager/ui-types";
|
||||
import { Page } from "@scm-manager/ui-components";
|
||||
import { getUsersLink } from "../../modules/indexResource";
|
||||
import { mustGetUsersLink } from "../../modules/indexResource";
|
||||
import { createUser, createUserReset, getCreateUserFailure, isCreateUserPending } from "../modules/users";
|
||||
import UserForm from "../components/UserForm";
|
||||
|
||||
@@ -84,7 +84,7 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
const mapStateToProps = (state: any) => {
|
||||
const loading = isCreateUserPending(state);
|
||||
const error = getCreateUserFailure(state);
|
||||
const usersLink = getUsersLink(state);
|
||||
const usersLink = mustGetUsersLink(state);
|
||||
return {
|
||||
usersLink,
|
||||
loading,
|
||||
|
||||
@@ -43,10 +43,9 @@ import EditUser from "./EditUser";
|
||||
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
|
||||
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { getUsersLink } from "../../modules/indexResource";
|
||||
import { mustGetUsersLink } from "../../modules/indexResource";
|
||||
import SetUserPassword from "../components/SetUserPassword";
|
||||
import SetPermissions from "../../permissions/components/SetPermissions";
|
||||
import AddPublicKey from "../components/publicKeys/AddPublicKey";
|
||||
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
@@ -148,7 +147,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const user = getUserByName(state, name);
|
||||
const loading = isFetchUserPending(state, name);
|
||||
const error = getFetchUserFailure(state, name);
|
||||
const usersLink = getUsersLink(state);
|
||||
const usersLink = mustGetUsersLink(state);
|
||||
return {
|
||||
usersLink,
|
||||
name,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
PageActions,
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getUsersLink } from "../../modules/indexResource";
|
||||
import { mustGetUsersLink } from "../../modules/indexResource";
|
||||
import {
|
||||
fetchUsersByPage,
|
||||
getFetchUsersFailure,
|
||||
@@ -129,7 +129,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const page = urls.getPageFromMatch(match);
|
||||
const canAddUsers = isPermittedToCreateUsers(state);
|
||||
const list = selectListAsCollection(state);
|
||||
const usersLink = getUsersLink(state);
|
||||
const usersLink = mustGetUsersLink(state);
|
||||
|
||||
return {
|
||||
users,
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
<dependency>
|
||||
<groupId>com.github.sdorra</groupId>
|
||||
<artifactId>spotter-core</artifactId>
|
||||
<version>2.1.2</version>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import sonia.scm.api.v2.resources.ErrorDto;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
import java.util.Collections;
|
||||
|
||||
@Provider
|
||||
public class WebApplicationExceptionMapper implements ExceptionMapper<WebApplicationException> {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WebApplicationExceptionMapper.class);
|
||||
|
||||
private static final String ERROR_CODE = "FVS9JY1T21";
|
||||
|
||||
@Override
|
||||
public Response toResponse(WebApplicationException exception) {
|
||||
LOG.trace("caught web application exception", exception);
|
||||
|
||||
ErrorDto errorDto = new ErrorDto();
|
||||
errorDto.setMessage(exception.getMessage());
|
||||
errorDto.setContext(Collections.emptyList());
|
||||
errorDto.setErrorCode(ERROR_CODE);
|
||||
errorDto.setTransactionId(MDC.get("transaction_id"));
|
||||
|
||||
Response originalResponse = exception.getResponse();
|
||||
|
||||
return Response.fromResponse(originalResponse)
|
||||
.entity(errorDto)
|
||||
.type(VndMediaType.ERROR_TYPE)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2;
|
||||
|
||||
import com.github.sdorra.spotter.ContentType;
|
||||
import com.github.sdorra.spotter.ContentTypeDetector;
|
||||
import com.github.sdorra.spotter.Language;
|
||||
|
||||
public final class ContentTypeResolver {
|
||||
|
||||
private static final ContentTypeDetector PATH_BASED = ContentTypeDetector.builder()
|
||||
.defaultPathBased().boost(Language.MARKDOWN)
|
||||
.bestEffortMatch();
|
||||
|
||||
private static final ContentTypeDetector PATH_AND_CONTENT_BASED = ContentTypeDetector.builder()
|
||||
.defaultPathAndContentBased().boost(Language.MARKDOWN)
|
||||
.bestEffortMatch();
|
||||
|
||||
private ContentTypeResolver() {
|
||||
}
|
||||
|
||||
public static ContentType resolve(String path) {
|
||||
return PATH_BASED.detect(path);
|
||||
}
|
||||
|
||||
public static ContentType resolve(String path, byte[] contentPrefix) {
|
||||
return PATH_AND_CONTENT_BASED.detect(path, contentPrefix);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.github.sdorra.spotter.ContentType;
|
||||
import com.github.sdorra.spotter.ContentTypes;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
@@ -34,6 +33,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.api.v2.ContentTypeResolver;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
@@ -204,7 +204,7 @@ public class ContentResource {
|
||||
}
|
||||
|
||||
private void appendContentHeader(String path, byte[] head, Response.ResponseBuilder responseBuilder) {
|
||||
ContentType contentType = ContentTypes.detect(path, head);
|
||||
ContentType contentType = ContentTypeResolver.resolve(path, head);
|
||||
responseBuilder.header("Content-Type", contentType.getRaw());
|
||||
contentType.getLanguage().ifPresent(
|
||||
language -> responseBuilder.header(ProgrammingLanguages.HEADER, ProgrammingLanguages.getValue(language))
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.github.sdorra.spotter.ContentTypes;
|
||||
import com.github.sdorra.spotter.Language;
|
||||
import com.google.inject.Inject;
|
||||
import de.otto.edison.hal.Links;
|
||||
import sonia.scm.api.v2.ContentTypeResolver;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.api.DiffFile;
|
||||
import sonia.scm.repository.api.DiffLine;
|
||||
@@ -120,7 +120,7 @@ class DiffResultToDiffResultDtoMapper {
|
||||
dto.setOldRevision(file.getOldRevision());
|
||||
|
||||
|
||||
Optional<Language> language = ContentTypes.detect(path).getLanguage();
|
||||
Optional<Language> language = ContentTypeResolver.resolve(path).getLanguage();
|
||||
language.ifPresent(value -> dto.setLanguage(ProgrammingLanguages.getValue(value)));
|
||||
|
||||
List<DiffResultDto.HunkDto> hunks = new ArrayList<>();
|
||||
|
||||
@@ -40,7 +40,6 @@ import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
import static sonia.scm.NotFoundException.notFound;
|
||||
@@ -88,7 +87,7 @@ public class SourceRootResource {
|
||||
browseCommand.setPath(path);
|
||||
browseCommand.setOffset(offset);
|
||||
if (revision != null && !revision.isEmpty()) {
|
||||
browseCommand.setRevision(URLDecoder.decode(revision, "UTF-8"));
|
||||
browseCommand.setRevision(revision);
|
||||
}
|
||||
BrowserResult browserResult = browseCommand.getBrowserResult();
|
||||
|
||||
|
||||
@@ -31,9 +31,8 @@ import javax.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class DefaultRestarter implements Restarter {
|
||||
|
||||
private ScmEventBus eventBus;
|
||||
private RestartStrategy strategy;
|
||||
private final ScmEventBus eventBus;
|
||||
private final RestartStrategy strategy;
|
||||
|
||||
@Inject
|
||||
public DefaultRestarter() {
|
||||
|
||||
@@ -61,6 +61,7 @@ import static java.util.Comparator.comparing;
|
||||
class MigrationWizardServlet extends HttpServlet {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardServlet.class);
|
||||
static final String PROPERTY_WAIT_TIME = "sonia.scm.restart-migration.wait";
|
||||
|
||||
private final XmlRepositoryV1UpdateStep repositoryV1UpdateStep;
|
||||
private final DefaultMigrationStrategyDAO migrationStrategyDao;
|
||||
@@ -145,7 +146,7 @@ class MigrationWizardServlet extends HttpServlet {
|
||||
|
||||
if (restarter.isSupported()) {
|
||||
respondWithTemplate(resp, model, "templates/repository-migration-restart.mustache");
|
||||
restarter.restart(MigrationWizardServlet.class, "wrote migration data");
|
||||
new Thread(this::restart).start();
|
||||
} else {
|
||||
respondWithTemplate(resp, model, "templates/repository-migration-manual-restart.mustache");
|
||||
LOG.error("Restarting is not supported on this platform.");
|
||||
@@ -153,6 +154,19 @@ class MigrationWizardServlet extends HttpServlet {
|
||||
}
|
||||
}
|
||||
|
||||
private void restart() {
|
||||
String wait = System.getProperty(PROPERTY_WAIT_TIME);
|
||||
if (!Strings.isNullOrEmpty(wait)) {
|
||||
try {
|
||||
Thread.sleep(Long.parseLong(wait));
|
||||
} catch (InterruptedException e) {
|
||||
LOG.error("error on waiting before restart", e);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
restarter.restart(MigrationWizardServlet.class, "wrote migration data");
|
||||
}
|
||||
|
||||
private List<RepositoryLineEntry> getRepositoryLineEntries() {
|
||||
List<V1Repository> repositoriesWithoutMigrationStrategies =
|
||||
repositoryV1UpdateStep.getRepositoriesWithoutMigrationStrategies();
|
||||
|
||||
@@ -31,14 +31,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.github.legman.Subscribe;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.SCMContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.Stage;
|
||||
import sonia.scm.lifecycle.RestartEvent;
|
||||
import sonia.scm.cache.Cache;
|
||||
import sonia.scm.cache.CacheManager;
|
||||
import sonia.scm.filter.WebElement;
|
||||
import sonia.scm.lifecycle.RestartEvent;
|
||||
import sonia.scm.plugin.PluginLoader;
|
||||
|
||||
import javax.inject.Inject;
|
||||
@@ -51,11 +51,6 @@ import java.net.URL;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
import static sonia.scm.NotFoundException.notFound;
|
||||
|
||||
|
||||
/**
|
||||
@@ -63,86 +58,90 @@ import static sonia.scm.NotFoundException.notFound;
|
||||
*/
|
||||
@Singleton
|
||||
@WebElement(value = I18nServlet.PATTERN, regex = true)
|
||||
@Slf4j
|
||||
public class I18nServlet extends HttpServlet {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(I18nServlet.class);
|
||||
|
||||
public static final String PLUGINS_JSON = "plugins.json";
|
||||
public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON;
|
||||
public static final String CACHE_NAME = "sonia.cache.plugins.translations";
|
||||
|
||||
private final SCMContextProvider context;
|
||||
private final ClassLoader classLoader;
|
||||
private final Cache<String, JsonNode> cache;
|
||||
private static ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
|
||||
@Inject
|
||||
public I18nServlet(PluginLoader pluginLoader, CacheManager cacheManager) {
|
||||
public I18nServlet(SCMContextProvider context, PluginLoader pluginLoader, CacheManager cacheManager) {
|
||||
this.context = context;
|
||||
this.classLoader = pluginLoader.getUberClassLoader();
|
||||
this.cache = cacheManager.getCache(CACHE_NAME);
|
||||
}
|
||||
|
||||
@Subscribe(async = false)
|
||||
public void handleRestartEvent(RestartEvent event) {
|
||||
log.debug("Clear cache on restart event with reason {}", event.getReason());
|
||||
LOG.debug("Clear cache on restart event with reason {}", event.getReason());
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private JsonNode getCollectedJson(String path,
|
||||
Function<String, Optional<JsonNode>> jsonFileProvider,
|
||||
BiConsumer<String, JsonNode> createdJsonFileConsumer) {
|
||||
return Optional.ofNullable(jsonFileProvider.apply(path)
|
||||
.orElseGet(() -> {
|
||||
Optional<JsonNode> createdFile = collectJsonFile(path);
|
||||
createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map));
|
||||
return createdFile.orElse(null);
|
||||
}
|
||||
)).orElseThrow(() -> notFound(entity("jsonprovider", path)));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse response) {
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
|
||||
String path = request.getServletPath();
|
||||
try {
|
||||
Optional<JsonNode> json = findJson(path);
|
||||
if (json.isPresent()) {
|
||||
write(response, json.get());
|
||||
} else {
|
||||
LOG.debug("could not find translation at {}", path);
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Error on getting the translation of the plugins", ex);
|
||||
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private void write(HttpServletResponse response, JsonNode jsonNode) throws IOException {
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setContentType("application/json");
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
try (PrintWriter out = response.getWriter()) {
|
||||
String path = req.getServletPath();
|
||||
Function<String, Optional<JsonNode>> jsonFileProvider = usedPath -> Optional.empty();
|
||||
BiConsumer<String, JsonNode> createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath);
|
||||
|
||||
try (PrintWriter writer = response.getWriter()) {
|
||||
objectMapper.writeValue(writer, jsonNode);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<JsonNode> findJson(String path) throws IOException {
|
||||
if (isProductionStage()) {
|
||||
log.debug("In Production Stage get the plugin translations from the cache");
|
||||
jsonFileProvider = usedPath -> Optional.ofNullable(
|
||||
cache.get(usedPath));
|
||||
createdJsonFileConsumer = createdJsonFileConsumer
|
||||
.andThen((usedPath, jsonNode) -> log.debug("Put the created json File in the cache with the key {}", usedPath))
|
||||
.andThen(cache::put);
|
||||
return findJsonCached(path);
|
||||
}
|
||||
objectMapper.writeValue(out, getCollectedJson(path, jsonFileProvider, createdJsonFileConsumer));
|
||||
} catch (IOException e) {
|
||||
log.error("Error on getting the translation of the plugins", e);
|
||||
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
} catch (NotFoundException e) {
|
||||
log.error("Plugin translations are not found", e);
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return collectJsonFile(path);
|
||||
}
|
||||
|
||||
private Optional<JsonNode> findJsonCached(String path) throws IOException {
|
||||
JsonNode jsonNode = cache.get(path);
|
||||
if (jsonNode != null) {
|
||||
LOG.debug("return json node from cache for path {}", path);
|
||||
return Optional.of(jsonNode);
|
||||
}
|
||||
|
||||
LOG.debug("collect json for path {}", path);
|
||||
Optional<JsonNode> collected = collectJsonFile(path);
|
||||
collected.ifPresent(node -> cache.put(path, node));
|
||||
return collected;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
protected boolean isProductionStage() {
|
||||
return SCMContext.getContext().getStage() == Stage.PRODUCTION;
|
||||
return context.getStage() == Stage.PRODUCTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a collected Json File as JsonNode from the given path from all plugins in the class path
|
||||
*
|
||||
* @param path the searched resource path
|
||||
* @return a collected Json File as JsonNode from the given path from all plugins in the class path
|
||||
*/
|
||||
@VisibleForTesting
|
||||
protected Optional<JsonNode> collectJsonFile(String path) {
|
||||
log.debug("Collect plugin translations from path {} for every plugin", path);
|
||||
private Optional<JsonNode> collectJsonFile(String path) throws IOException {
|
||||
LOG.debug("Collect plugin translations from path {} for every plugin", path);
|
||||
JsonNode mergedJsonNode = null;
|
||||
try {
|
||||
Enumeration<URL> resources = classLoader.getResources(path.replaceFirst("/", ""));
|
||||
while (resources.hasMoreElements()) {
|
||||
URL url = resources.nextElement();
|
||||
@@ -153,32 +152,15 @@ public class I18nServlet extends HttpServlet {
|
||||
mergedJsonNode = jsonNode;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error on loading sources from {}", path, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.ofNullable(mergedJsonNode);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Merge the <code>updateNode</code> into the <code>mainNode</code> and return it.
|
||||
*
|
||||
* This is not a deep merge.
|
||||
*
|
||||
* @param mainNode the main node
|
||||
* @param updateNode the update node
|
||||
* @return the merged mainNode
|
||||
*/
|
||||
@VisibleForTesting
|
||||
protected JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
|
||||
private JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
|
||||
Iterator<String> fieldNames = updateNode.fieldNames();
|
||||
|
||||
while (fieldNames.hasNext()) {
|
||||
|
||||
String fieldName = fieldNames.next();
|
||||
JsonNode jsonNode = mainNode.get(fieldName);
|
||||
|
||||
if (jsonNode != null) {
|
||||
mergeNode(updateNode, fieldName, jsonNode);
|
||||
} else {
|
||||
|
||||
@@ -268,6 +268,10 @@
|
||||
"BxS5wX2v71": {
|
||||
"displayName": "Inkorrekter Schlüssel",
|
||||
"description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel."
|
||||
},
|
||||
"FVS9JY1T21": {
|
||||
"displayName": "Fehler bei der Anfrage",
|
||||
"description": "Bei der Anfrage trat ein Fehler auf. Prüfen Sie bitte den Status der HTTP Antwort und die konkrete Meldung."
|
||||
}
|
||||
},
|
||||
"namespaceStrategies": {
|
||||
|
||||
@@ -268,6 +268,10 @@
|
||||
"BxS5wX2v71": {
|
||||
"displayName": "Invalid key",
|
||||
"description": "The provided key is not a valid public key."
|
||||
},
|
||||
"FVS9JY1T21": {
|
||||
"displayName": "Error in the request",
|
||||
"description": "While processing the request there was an error. Please check the http return status and the concrete error message."
|
||||
}
|
||||
},
|
||||
"namespaceStrategies": {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2;
|
||||
|
||||
import com.github.sdorra.spotter.ContentType;
|
||||
import com.github.sdorra.spotter.Language;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContentTypeResolverTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveMarkdown() {
|
||||
String content = String.join("\n",
|
||||
"% Markdown content",
|
||||
"% Which does not start with markdown"
|
||||
);
|
||||
ContentType contentType = ContentTypeResolver.resolve("somedoc.md", content.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveMarkdownWithoutContent() {
|
||||
ContentType contentType = ContentTypeResolver.resolve("somedoc.md");
|
||||
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveMarkdownEvenWithDotsInFilename() {
|
||||
ContentType contentType = ContentTypeResolver.resolve("somedoc.1.1.md");
|
||||
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveDockerfile() {
|
||||
ContentType contentType = ContentTypeResolver.resolve("Dockerfile");
|
||||
assertThat(contentType.getLanguage()).contains(Language.DOCKERFILE);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,6 @@
|
||||
*/
|
||||
package sonia.scm.lifecycle;
|
||||
|
||||
import com.github.legman.Subscribe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -32,8 +31,6 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.event.ScmEventBus;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
package sonia.scm.update;
|
||||
|
||||
import com.google.common.base.Stopwatch;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -37,13 +38,19 @@ import sonia.scm.update.repository.XmlRepositoryV1UpdateStep;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static sonia.scm.update.MigrationWizardServlet.PROPERTY_WAIT_TIME;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MigrationWizardServletTest {
|
||||
@@ -262,4 +269,58 @@ class MigrationWizardServletTest {
|
||||
|
||||
verify(migrationStrategyDao).set("id", "git", "name", MigrationStrategy.COPY, "namespace", "name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRestartAfterValidMigration() throws InterruptedException {
|
||||
|
||||
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
|
||||
Collections.singletonList(new V1Repository("id", "git", "name"))
|
||||
);
|
||||
doReturn("namespace").when(request).getParameter("namespace-id");
|
||||
doReturn("name").when(request).getParameter("name-id");
|
||||
doReturn("COPY").when(request).getParameter("strategy-id");
|
||||
|
||||
final CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||
when(restarter.isSupported()).thenReturn(true);
|
||||
doAnswer(ic -> {
|
||||
countDownLatch.countDown();
|
||||
return null;
|
||||
}).when(restarter).restart(any(), any());
|
||||
|
||||
servlet.doPost(request, response);
|
||||
|
||||
boolean restarted = countDownLatch.await(500L, TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(restarted).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRestartAfterValidMigrationWithSystemProperty() throws InterruptedException {
|
||||
System.setProperty(PROPERTY_WAIT_TIME, "100");
|
||||
Stopwatch stopwatch = Stopwatch.createStarted();
|
||||
|
||||
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
|
||||
Collections.singletonList(new V1Repository("id", "git", "name"))
|
||||
);
|
||||
doReturn("namespace").when(request).getParameter("namespace-id");
|
||||
doReturn("name").when(request).getParameter("name-id");
|
||||
doReturn("COPY").when(request).getParameter("strategy-id");
|
||||
|
||||
final CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||
when(restarter.isSupported()).thenReturn(true);
|
||||
doAnswer(ic -> {
|
||||
stopwatch.stop();
|
||||
countDownLatch.countDown();
|
||||
return null;
|
||||
}).when(restarter).restart(any(), any());
|
||||
|
||||
servlet.doPost(request, response);
|
||||
|
||||
boolean restarted = countDownLatch.await(750L, TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(stopwatch.elapsed()).isGreaterThanOrEqualTo(Duration.ofMillis(100L));
|
||||
assertThat(restarted).isTrue();
|
||||
|
||||
System.clearProperty(PROPERTY_WAIT_TIME);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,44 +26,38 @@ package sonia.scm.web.i18n;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.base.Charsets;
|
||||
import com.google.common.io.Files;
|
||||
import com.github.legman.EventBus;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.assertj.core.util.Lists;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockSettings;
|
||||
import org.mockito.internal.creation.MockSettingsImpl;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import sonia.scm.lifecycle.RestartEvent;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.Stage;
|
||||
import sonia.scm.cache.Cache;
|
||||
import sonia.scm.cache.CacheManager;
|
||||
import sonia.scm.event.ScmEventBus;
|
||||
import sonia.scm.lifecycle.RestartEventFactory;
|
||||
import sonia.scm.plugin.PluginLoader;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@RunWith(MockitoJUnitRunner.Silent.class)
|
||||
public class I18nServletTest {
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class I18nServletTest {
|
||||
|
||||
private static final String GIT_PLUGIN_JSON = json(
|
||||
"{",
|
||||
@@ -76,6 +70,7 @@ public class I18nServletTest {
|
||||
"}",
|
||||
"}"
|
||||
);
|
||||
|
||||
private static final String HG_PLUGIN_JSON = json(
|
||||
"{",
|
||||
"'scm-hg-plugin': {",
|
||||
@@ -87,7 +82,8 @@ public class I18nServletTest {
|
||||
"}",
|
||||
"}"
|
||||
);
|
||||
private static String SVN_PLUGIN_JSON = json(
|
||||
|
||||
private static final String SVN_PLUGIN_JSON = json(
|
||||
"{",
|
||||
"'scm-svn-plugin': {",
|
||||
"'information': {",
|
||||
@@ -101,59 +97,49 @@ public class I18nServletTest {
|
||||
return String.join("\n", parts ).replaceAll("'", "\"");
|
||||
}
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
@Mock
|
||||
private SCMContextProvider context;
|
||||
|
||||
@Mock
|
||||
private PluginLoader pluginLoader;
|
||||
|
||||
@Mock
|
||||
private ClassLoader classLoader;
|
||||
|
||||
@Mock
|
||||
private CacheManager cacheManager;
|
||||
|
||||
@Mock
|
||||
private ClassLoader classLoader;
|
||||
private Cache<String, JsonNode> cache;
|
||||
|
||||
private I18nServlet servlet;
|
||||
|
||||
@Mock
|
||||
private Cache cache;
|
||||
private Enumeration<URL> resources;
|
||||
|
||||
@Before
|
||||
@SuppressWarnings("unchecked")
|
||||
public void init() throws IOException {
|
||||
resources = Collections.enumeration(Lists.newArrayList(
|
||||
createFileFromString(SVN_PLUGIN_JSON).toURI().toURL(),
|
||||
createFileFromString(GIT_PLUGIN_JSON).toURI().toURL(),
|
||||
createFileFromString(HG_PLUGIN_JSON).toURI().toURL()
|
||||
));
|
||||
@BeforeEach
|
||||
void init() {
|
||||
when(pluginLoader.getUberClassLoader()).thenReturn(classLoader);
|
||||
when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
|
||||
MockSettings settings = new MockSettingsImpl<>();
|
||||
settings.useConstructor(pluginLoader, cacheManager);
|
||||
settings.defaultAnswer(InvocationOnMock::callRealMethod);
|
||||
servlet = mock(I18nServlet.class, settings);
|
||||
when(cacheManager.<String, JsonNode>getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
|
||||
servlet = new I18nServlet(context, pluginLoader, cacheManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCleanCacheOnRestartEvent() {
|
||||
ScmEventBus.getInstance().register(servlet);
|
||||
|
||||
ScmEventBus.getInstance().post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources"));
|
||||
void shouldCleanCacheOnRestartEvent() {
|
||||
EventBus eventBus = new EventBus("forTestingOnly");
|
||||
eventBus.register(servlet);
|
||||
eventBus.post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources"));
|
||||
|
||||
verify(cache).clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldFailWith404OnMissingResources() throws IOException {
|
||||
void shouldFailWith404OnMissingResources() throws IOException {
|
||||
String path = "/locales/de/plugins.json";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
PrintWriter writer = mock(PrintWriter.class);
|
||||
when(response.getWriter()).thenReturn(writer);
|
||||
when(request.getServletPath()).thenReturn(path);
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenThrow(IOException.class);
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(
|
||||
I18nServlet.class.getClassLoader().getResources("something/not/available")
|
||||
);
|
||||
|
||||
servlet.doGet(request, response);
|
||||
|
||||
@@ -161,98 +147,96 @@ public class I18nServletTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldFailWith500OnIOException() throws IOException {
|
||||
void shouldFailWith500OnIOException() throws IOException {
|
||||
stage(Stage.DEVELOPMENT);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
doThrow(IOException.class).when(response).getWriter();
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenThrow(new IOException("failed"));
|
||||
|
||||
servlet.doGet(request, response);
|
||||
|
||||
verify(response).setStatus(500);
|
||||
}
|
||||
|
||||
private void stage(Stage stage) {
|
||||
when(context.getStage()).thenReturn(stage);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void inDevelopmentStageShouldNotUseCache() throws IOException {
|
||||
String path = "/locales/de/plugins.json";
|
||||
when(servlet.isProductionStage()).thenReturn(false);
|
||||
void inDevelopmentStageShouldNotUseCache(@TempDir Path temp) throws IOException {
|
||||
stage(Stage.DEVELOPMENT);
|
||||
mockResources(temp, "locales/de/plugins.json");
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
|
||||
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
File file = temporaryFolder.newFile();
|
||||
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
|
||||
when(response.getWriter()).thenReturn(writer);
|
||||
when(request.getServletPath()).thenReturn(path);
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
|
||||
String json = doGetString(request, response);
|
||||
|
||||
servlet.doGet(request, response);
|
||||
|
||||
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
|
||||
assertJson(json);
|
||||
verify(cache, never()).get(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void inProductionStageShouldUseCache() throws IOException {
|
||||
String path = "/locales/de/plugins.json";
|
||||
when(servlet.isProductionStage()).thenReturn(true);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
File file = temporaryFolder.newFile();
|
||||
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
|
||||
private String doGetString(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PrintWriter writer = new PrintWriter(baos);
|
||||
when(response.getWriter()).thenReturn(writer);
|
||||
when(request.getServletPath()).thenReturn(path);
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
|
||||
|
||||
servlet.doGet(request, response);
|
||||
|
||||
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
|
||||
assertJson(json);
|
||||
verify(cache).get(path);
|
||||
verify(cache).put(eq(path), any());
|
||||
writer.flush();
|
||||
return baos.toString(StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
private void mockResources(Path directory, String resourcePath) throws IOException {
|
||||
Enumeration<URL> resources = Collections.enumeration(
|
||||
Arrays.asList(
|
||||
toURL(directory, "git.json", GIT_PLUGIN_JSON),
|
||||
toURL(directory, "hg.json", HG_PLUGIN_JSON),
|
||||
toURL(directory, "svn.json", SVN_PLUGIN_JSON)
|
||||
)
|
||||
);
|
||||
when(classLoader.getResources(resourcePath)).thenReturn(resources);
|
||||
}
|
||||
|
||||
private URL toURL(Path directory, String name, String content) throws IOException {
|
||||
Path file = directory.resolve(name);
|
||||
java.nio.file.Files.write(file, content.getBytes(StandardCharsets.UTF_8));
|
||||
return file.toUri().toURL();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetFromCacheInProductionStage() throws IOException {
|
||||
String path = "/locales/de/plugins.json";
|
||||
stage(Stage.PRODUCTION);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getServletPath()).thenReturn(path);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode jsonNode = mapper.readTree(GIT_PLUGIN_JSON);
|
||||
when(cache.get(path)).thenReturn(jsonNode);
|
||||
|
||||
String json = doGetString(request, response);
|
||||
assertThat(json).contains("scm-git-plugin").doesNotContain("scm-hg-plugin");
|
||||
verifyHeaders(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void inProductionStageShouldGetFromCache() throws IOException {
|
||||
void shouldStoreToCacheInProductionStage(@TempDir Path temp) throws IOException {
|
||||
String path = "/locales/de/plugins.json";
|
||||
when(servlet.isProductionStage()).thenReturn(true);
|
||||
mockResources(temp, "locales/de/plugins.json");
|
||||
stage(Stage.PRODUCTION);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
File file = temporaryFolder.newFile();
|
||||
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
|
||||
when(response.getWriter()).thenReturn(writer);
|
||||
when(request.getServletPath()).thenReturn(path);
|
||||
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
JsonNode node = objectMapper.readTree(GIT_PLUGIN_JSON);
|
||||
node = servlet.merge(node, objectMapper.readTree(HG_PLUGIN_JSON));
|
||||
node = servlet.merge(node, objectMapper.readTree(SVN_PLUGIN_JSON));
|
||||
when(cache.get(path)).thenReturn(node);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
|
||||
servlet.doGet(request, response);
|
||||
String json = doGetString(request, response);
|
||||
|
||||
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
|
||||
verify(servlet, never()).collectJsonFile(path);
|
||||
verify(cache, never()).put(eq(path), any());
|
||||
verify(cache).get(path);
|
||||
assertJson(json);
|
||||
verify(cache).put(any(String.class), any(JsonNode.class));
|
||||
|
||||
verifyHeaders(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldCollectJsonFile() throws IOException {
|
||||
String path = "locales/de/plugins.json";
|
||||
when(classLoader.getResources(path)).thenReturn(resources);
|
||||
|
||||
Optional<JsonNode> jsonNodeOptional = servlet.collectJsonFile("/" + path);
|
||||
|
||||
assertJson(jsonNodeOptional.orElse(null));
|
||||
assertJson(json);
|
||||
}
|
||||
|
||||
private void verifyHeaders(HttpServletResponse response) {
|
||||
@@ -261,22 +245,11 @@ public class I18nServletTest {
|
||||
verify(response).setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
|
||||
public void assertJson(JsonNode actual) throws IOException {
|
||||
assertJson(actual.toString());
|
||||
}
|
||||
|
||||
private void assertJson(String actual) throws IOException {
|
||||
private void assertJson(String actual) {
|
||||
assertThat(actual)
|
||||
.isNotEmpty()
|
||||
.contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1)))
|
||||
.contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1)))
|
||||
.contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1)));
|
||||
}
|
||||
|
||||
private File createFileFromString(String json) throws IOException {
|
||||
File file = temporaryFolder.newFile();
|
||||
Files.write(json.getBytes(Charsets.UTF_8), file);
|
||||
return file;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
42
yarn.lock
42
yarn.lock
@@ -3397,6 +3397,13 @@
|
||||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/redux-logger@^3.0.8":
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.8.tgz#1fb6d26917bb198792bb1cf57feb31cae1532c5d"
|
||||
integrity sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==
|
||||
dependencies:
|
||||
redux "^4.0.0"
|
||||
|
||||
"@types/sinonjs__fake-timers@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
|
||||
@@ -4287,6 +4294,11 @@ babel-code-frame@^6.22.0:
|
||||
esutils "^2.0.2"
|
||||
js-tokens "^3.0.2"
|
||||
|
||||
babel-core@7.0.0-bridge.0:
|
||||
version "7.0.0-bridge.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
|
||||
integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
|
||||
|
||||
babel-eslint@^10.0.3:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
|
||||
@@ -7786,7 +7798,7 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
|
||||
|
||||
fault@^1.0.0, fault@^1.0.2:
|
||||
fault@^1.0.0:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13"
|
||||
integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==
|
||||
@@ -8362,7 +8374,7 @@ gitconfiglocal@^1.0.0:
|
||||
dependencies:
|
||||
ini "^1.3.2"
|
||||
|
||||
gitdiff-parser@^0.1.2:
|
||||
gitdiff-parser@^0.1.2, "gitdiff-parser@https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d":
|
||||
version "0.1.2"
|
||||
resolved "https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d"
|
||||
|
||||
@@ -11350,7 +11362,7 @@ lower-case@^2.0.1:
|
||||
dependencies:
|
||||
tslib "^1.10.0"
|
||||
|
||||
lowlight@^1.13.0:
|
||||
lowlight@1.13.1, lowlight@^1.13.0, lowlight@~1.11.0:
|
||||
version "1.13.1"
|
||||
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.1.tgz#c4f0e03906ebd23fedf2d258f6ab2f6324cf90eb"
|
||||
integrity sha512-kQ71/T6RksEVz9AlPq07/2m+SU/1kGvt9k39UtvHX760u4SaWakaYH7hYgH5n6sTsCWk4MVYzUzLU59aN5CSmQ==
|
||||
@@ -11358,14 +11370,6 @@ lowlight@^1.13.0:
|
||||
fault "^1.0.0"
|
||||
highlight.js "~9.16.0"
|
||||
|
||||
lowlight@~1.11.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc"
|
||||
integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A==
|
||||
dependencies:
|
||||
fault "^1.0.2"
|
||||
highlight.js "~9.13.0"
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||
@@ -11715,20 +11719,20 @@ mini-create-react-context@^0.4.0:
|
||||
"@babel/runtime" "^7.5.5"
|
||||
tiny-warning "^1.0.3"
|
||||
|
||||
mini-css-extract-plugin@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
|
||||
integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
|
||||
mini-css-extract-plugin@^0.10.0:
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.10.1.tgz#2592c891f965e15750da6a6c0b60740b5b0cb62d"
|
||||
integrity sha512-9B10gZixtNjHerADBrMxPXM5G0uL0CRGMcLRV67I8nd1SKbwJrI0okKUzD+PxKsUZ9Dxt8/hPvtzF0DrRnrOyA==
|
||||
dependencies:
|
||||
loader-utils "^1.1.0"
|
||||
normalize-url "1.9.1"
|
||||
schema-utils "^1.0.0"
|
||||
webpack-sources "^1.1.0"
|
||||
|
||||
mini-css-extract-plugin@^0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e"
|
||||
integrity sha512-lp3GeY7ygcgAmVIcRPBVhIkf8Us7FZjA+ILpal44qLdSu11wmjKQ3d9k15lfD7pO4esu9eUIAW7qiYIBppv40A==
|
||||
mini-css-extract-plugin@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
|
||||
integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
|
||||
dependencies:
|
||||
loader-utils "^1.1.0"
|
||||
normalize-url "1.9.1"
|
||||
|
||||
Reference in New Issue
Block a user