Merge branch 'develop' into bugfix/markdown_view_anchor_links

This commit is contained in:
Sebastian Sdorra
2020-08-13 10:12:46 +02:00
committed by GitHub
259 changed files with 14716 additions and 1557 deletions

View File

@@ -23,6 +23,7 @@
*/
import React from "react";
import classNames from "classnames";
import { createAttributesForTesting } from "./devBuild";
type Props = {
title?: string;
@@ -31,6 +32,7 @@ type Props = {
color: string;
className?: string;
onClick?: () => void;
testId?: string;
};
export default class Icon extends React.Component<Props> {
@@ -40,12 +42,23 @@ export default class Icon extends React.Component<Props> {
};
render() {
const { title, iconStyle, name, color, className, onClick } = this.props;
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
if (title) {
return (
<i onClick={onClick} title={title} className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)} />
<i
onClick={onClick}
title={title}
className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
);
}
return <i onClick={onClick} className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)} />;
return (
<i
onClick={onClick}
className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
);
}
}

View File

@@ -32,11 +32,12 @@ type Props = RouteComponentProps & {
showCreateButton: boolean;
link: string;
label?: string;
testId?: string;
};
class OverviewPageActions extends React.Component<Props> {
render() {
const { history, location, link } = this.props;
const { history, location, link, testId } = this.props;
return (
<>
<FilterInput
@@ -44,6 +45,7 @@ class OverviewPageActions extends React.Component<Props> {
filter={filter => {
history.push(`/${link}/?q=${filter}`);
}}
testId={testId + "-filter"}
/>
{this.renderCreateButton()}
</>

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ import React, { MouseEvent, ReactNode } from "react";
import classNames from "classnames";
import { withRouter, RouteComponentProps } from "react-router-dom";
import Icon from "../Icon";
import { createAttributesForTesting } from "../devBuild";
export type ButtonProps = {
label?: string;
@@ -37,6 +38,7 @@ export type ButtonProps = {
fullWidth?: boolean;
reducedMobile?: boolean;
children?: ReactNode;
testId?: string;
};
type Props = ButtonProps &
@@ -73,7 +75,8 @@ class Button extends React.Component<Props> {
icon,
fullWidth,
reducedMobile,
children
children,
testId
} = this.props;
const loadingClass = loading ? "is-loading" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
@@ -86,6 +89,7 @@ class Button extends React.Component<Props> {
disabled={disabled}
onClick={this.onClick}
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)}
{...createAttributesForTesting(testId)}
>
<span className="icon is-medium">
<Icon name={icon} color="inherit" />
@@ -104,6 +108,7 @@ class Button extends React.Component<Props> {
disabled={disabled}
onClick={this.onClick}
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)}
{...createAttributesForTesting(testId)}
>
{label} {children}
</button>

View File

@@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button";
type SubmitButtonProps = ButtonProps & {
scrollToTop: boolean;
testId?: string;
};
class SubmitButton extends React.Component<SubmitButtonProps> {
@@ -34,7 +35,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
};
render() {
const { action, scrollToTop } = this.props;
const { action, scrollToTop, testId } = this.props;
return (
<Button
type="submit"
@@ -48,6 +49,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
window.scrollTo(0, 0);
}
}}
testId={testId ? testId : "submit-button"}
/>
);
}

View File

@@ -0,0 +1,94 @@
/*
* 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 { createAttributesForTesting, isDevBuild } from "./devBuild";
describe("devbuild tests", () => {
let env: string | undefined;
beforeAll(() => {
env = process.env.NODE_ENV;
});
afterAll(() => {
process.env.NODE_ENV = env;
});
describe("isDevBuild tests", () => {
it("should return true for development", () => {
process.env.NODE_ENV = "development";
expect(isDevBuild()).toBe(true);
});
it("should return false for production", () => {
process.env.NODE_ENV = "production";
expect(isDevBuild()).toBe(false);
});
});
describe("createAttributesForTesting in non development mode", () => {
beforeAll(() => {
process.env.NODE_ENV = "production";
});
it("should return undefined for non development", () => {
const attributes = createAttributesForTesting("123");
expect(attributes).toBeUndefined();
});
});
describe("createAttributesForTesting in development mode", () => {
beforeAll(() => {
process.env.NODE_ENV = "development";
});
it("should return undefined for non development", () => {
const attributes = createAttributesForTesting("123");
expect(attributes).toBeDefined();
});
it("should return undefined for undefined testid", () => {
const attributes = createAttributesForTesting();
expect(attributes).toBeUndefined();
});
it("should remove spaces from test id", () => {
const attributes = createAttributesForTesting("heart of gold");
if (attributes) {
expect(attributes["data-testid"]).toBe("heart-of-gold");
} else {
throw new Error("attributes should be defined");
}
});
it("should lower case test id", () => {
const attributes = createAttributesForTesting("HeartOfGold");
if (attributes) {
expect(attributes["data-testid"]).toBe("heartofgold");
} else {
throw new Error("attributes should be defined");
}
});
});
});

View File

@@ -0,0 +1,42 @@
/*
* 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.
*/
export const isDevBuild = () => process.env.NODE_ENV === "development";
export const createAttributesForTesting = (testId?: string) => {
if (!testId || !isDevBuild()) {
return undefined;
}
return {
"data-testid": normalizeTestId(testId)
};
};
const normalizeTestId = (testId: string) => {
let id = testId.toLowerCase();
while (id.includes(" ")) {
id = id.replace(" ", "-");
}
return id;
};

View File

@@ -35,6 +35,7 @@ type Props = {
title?: string;
disabled?: boolean;
helpText?: string;
testId?: string;
};
export default class Checkbox extends React.Component<Props> {
@@ -59,7 +60,7 @@ export default class Checkbox extends React.Component<Props> {
};
render() {
const { label, checked, indeterminate, disabled } = this.props;
const { label, checked, indeterminate, disabled, testId } = this.props;
return (
<div className="field">
{this.renderLabelWithHelp()}
@@ -70,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
but bulma does.
// @ts-ignore */}
<label className="checkbox" disabled={disabled}>
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} />
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
{label}
{this.renderHelp()}
</label>

View File

@@ -24,10 +24,12 @@
import React, { ChangeEvent, FormEvent } from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components";
import { createAttributesForTesting } from "../devBuild";
type Props = WithTranslation & {
filter: (p: string) => void;
value?: string;
testId?: string;
};
type State = {
@@ -58,9 +60,9 @@ class FilterInput extends React.Component<Props, State> {
};
render() {
const { t } = this.props;
const { t, testId } = this.props;
return (
<form className="input-field" onSubmit={this.handleSubmit}>
<form className="input-field" onSubmit={this.handleSubmit} {...createAttributesForTesting(testId)}>
<div className="control has-icons-left">
<FixedHeightInput
className="input"

View File

@@ -24,6 +24,7 @@
import React, { ChangeEvent, KeyboardEvent } from "react";
import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createAttributesForTesting } from "../devBuild";
type Props = {
label?: string;
@@ -39,6 +40,7 @@ type Props = {
disabled?: boolean;
helpText?: string;
className?: string;
testId?: string;
};
class InputField extends React.Component<Props> {
@@ -80,7 +82,8 @@ class InputField extends React.Component<Props> {
disabled,
label,
helpText,
className
className,
testId
} = this.props;
const errorView = validationError ? "is-danger" : "";
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
@@ -99,6 +102,7 @@ class InputField extends React.Component<Props> {
onChange={this.handleInput}
onKeyPress={this.handleKeyPress}
disabled={disabled}
{...createAttributesForTesting(testId)}
/>
</div>
{helper}

View File

@@ -24,6 +24,7 @@
import React, { ChangeEvent } from "react";
import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import {createAttributesForTesting} from "../devBuild";
export type SelectItem = {
value: string;
@@ -39,6 +40,7 @@ type Props = {
loading?: boolean;
helpText?: string;
disabled?: boolean;
testId?: string;
};
class Select extends React.Component<Props> {
@@ -57,7 +59,7 @@ class Select extends React.Component<Props> {
};
render() {
const { options, value, label, helpText, loading, disabled } = this.props;
const { options, value, label, helpText, loading, disabled, testId } = this.props;
const loadingClass = loading ? "is-loading" : "";
return (
@@ -71,6 +73,7 @@ class Select extends React.Component<Props> {
value={value}
onChange={this.handleInput}
disabled={disabled}
{...createAttributesForTesting(testId)}
>
{options.map(opt => {
return (

View File

@@ -29,9 +29,10 @@ type Props = {
indeterminate?: boolean;
disabled?: boolean;
label?: string;
testId?: string;
};
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }) => {
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label, testId }) => {
let icon;
if (indeterminate) {
icon = "minus-square";
@@ -57,8 +58,11 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }
color = "black";
}
return <><Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} />{" "}
{label}</>;
return (
<>
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
</>
);
};
export default TriStateCheckbox;

View File

@@ -85,6 +85,7 @@ export { default as comparators } from "./comparators";
export { apiClient } from "./apiclient";
export * from "./errors";
export { isDevBuild, createAttributesForTesting } from "./devBuild";
export * from "./avatar";
export * from "./buttons";
@@ -96,6 +97,7 @@ export * from "./navigation";
export * from "./repos";
export * from "./table";
export * from "./toast";
export * from "./popover";
export {
File,

View File

@@ -22,8 +22,8 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Me, Links } from "@scm-manager/ui-types";
import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { Links, Me } from "@scm-manager/ui-types";
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
import { AvatarImage } from "../avatar";
import NavLink from "../navigation/NavLink";
import FooterSection from "./FooterSection";
@@ -31,6 +31,7 @@ import styled from "styled-components";
import { EXTENSION_POINT } from "../avatar/Avatar";
import ExternalNavLink from "../navigation/ExternalNavLink";
import { useTranslation } from "react-i18next";
import { createAttributesForTesting } from "../devBuild";
type Props = {
me?: Me;
@@ -43,11 +44,13 @@ type TitleWithIconsProps = {
icon: string;
};
const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => (
<>
<i className={`fas fa-${icon} fa-fw`} /> {title}
</>
);
const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => {
return (
<>
<i className={`fas fa-${icon} fa-fw`} {...createAttributesForTesting(title)} /> {title}
</>
);
};
type TitleWithAvatarProps = {
me: Me;
@@ -66,12 +69,12 @@ const AvatarContainer = styled.span`
`;
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
<>
<div {...createAttributesForTesting(me.displayName)}>
<AvatarContainer className="image is-rounded">
<VCenteredAvatar person={me} representation="rounded" />
</AvatarContainer>
{me.displayName}
</>
</div>
);
const Footer: FC<Props> = ({ me, version, links }) => {
@@ -96,6 +99,9 @@ const Footer: FC<Props> = ({ me, version, links }) => {
<FooterSection title={meSectionTile}>
<NavLink to="/me" label={t("footer.user.profile")} />
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
{me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>

View File

@@ -28,14 +28,16 @@ import { RoutingProps } from "./RoutingProps";
import { FC } from "react";
import useMenuContext from "./MenuContext";
import useActiveMatch from "./useActiveMatch";
import {createAttributesForTesting} from "../devBuild";
type Props = RoutingProps & {
label: string;
title?: string;
icon?: string;
testId?: string;
};
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title }) => {
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, testId }) => {
const active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact });
const context = useMenuContext();
@@ -52,7 +54,11 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
return (
<li title={collapsed ? title : undefined}>
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")} to={to}>
<Link
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
to={to}
{...createAttributesForTesting(testId)}
>
{showIcon}
{collapsed ? null : label}
</Link>

View File

@@ -40,7 +40,7 @@ class PrimaryNavigation extends React.Component<Props> {
return (to: string, match: string, label: string, linkName: string) => {
const link = links[linkName];
if (link) {
const navigationItem = <PrimaryNavigationLink to={to} match={match} label={t(label)} key={linkName} />;
const navigationItem = <PrimaryNavigationLink testId={label.replace(".", "-")} to={to} match={match} label={t(label)} key={linkName} />;
navigationItems.push(navigationItem);
}
};
@@ -63,6 +63,23 @@ class PrimaryNavigation extends React.Component<Props> {
}
};
appendLogin = (navigationItems: ReactNode[], append: Appender) => {
const { t, links } = this.props;
const props = {
links,
label: t("primary-navigation.login")
};
if (binder.hasExtension("primary-navigation.login", props)) {
navigationItems.push(
<ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} />
);
} else {
append("/login", "/login", "primary-navigation.login", "login");
}
};
createNavigationItems = () => {
const navigationItems: ReactNode[] = [];
const { t, links } = this.props;
@@ -95,6 +112,7 @@ class PrimaryNavigation extends React.Component<Props> {
);
this.appendLogout(navigationItems, append);
this.appendLogin(navigationItems, append);
return navigationItems;
};

View File

@@ -23,28 +23,39 @@
*/
import * as React from "react";
import { Route, Link } from "react-router-dom";
import { createAttributesForTesting } from "../devBuild";
type Props = {
to: string;
label: string;
match?: string;
activeOnlyWhenExact?: boolean;
testId?: string;
};
class PrimaryNavigationLink extends React.Component<Props> {
renderLink = (route: any) => {
const { to, label } = this.props;
const { to, label, testId } = this.props;
return (
<li className={route.match ? "is-active" : ""}>
<Link to={to}>{label}</Link>
<Link to={to} {...createAttributesForTesting(testId)}>
{label}
</Link>
</li>
);
};
render() {
const { to, match, activeOnlyWhenExact } = this.props;
const { to, match, activeOnlyWhenExact, testId } = this.props;
const path = match ? match : to;
return <Route path={path} exact={activeOnlyWhenExact} children={this.renderLink} />;
return (
<Route
path={path}
exact={activeOnlyWhenExact}
children={this.renderLink}
{...createAttributesForTesting(testId)}
/>
);
}
}

View File

@@ -27,14 +27,25 @@ import classNames from "classnames";
import useMenuContext from "./MenuContext";
import { RoutingProps } from "./RoutingProps";
import useActiveMatch from "./useActiveMatch";
import { createAttributesForTesting } from "../devBuild";
type Props = RoutingProps & {
label: string;
title?: string;
icon?: string;
testId?: string;
};
const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, icon, title, label, children }) => {
const SubNavigation: FC<Props> = ({
to,
activeOnlyWhenExact,
activeWhenMatch,
icon,
title,
label,
children,
testId
}) => {
const context = useMenuContext();
const collapsed = context.isCollapsed();
@@ -60,7 +71,11 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, ic
return (
<li title={collapsed ? title : undefined}>
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")} to={to}>
<Link
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
to={to}
{...createAttributesForTesting(testId)}
>
<i className={classNames(defaultIcon, "fa-fw")} /> {collapsed ? "" : label}
</Link>
{childrenList}

View File

@@ -0,0 +1,73 @@
/*
* 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 { storiesOf } from "@storybook/react";
import React from "react";
import styled from "styled-components";
import usePopover from "./usePopover";
import Popover from "./Popover";
const Wrapper = styled.div`
width: 100%;
margin: 20rem;
`;
storiesOf("Popover", module)
.addDecorator(storyFn => <Wrapper>{storyFn()}</Wrapper>)
.add("Default", () => React.createElement(() => {
const { triggerProps, popoverProps } = usePopover();
return (
<div>
<Popover title={<strong>Spaceship Heart of Gold</strong>} width={512} {...popoverProps}>
<p>
The Heart of Gold is the sleekest, most advanced, coolest spaceship in the galaxy. Its stunning good looks
mirror its awesome speed and power. It is powered by the revolutionary new Infinite Improbability Drive,
which lets the ship pass through every point in every universe simultaneously.
</p>
</Popover>
<button className="button" {...triggerProps}>
Trigger
</button>
</div>
);
}))
.add("Link", () => React.createElement(() => {
const { triggerProps, popoverProps } = usePopover();
return (
<div>
<Popover title={<strong>Spaceship Heart of Gold</strong>} width={512} {...popoverProps}>
<p>
The Heart of Gold is the sleekest, most advanced, coolest spaceship in the galaxy. Its stunning good looks
mirror its awesome speed and power. It is powered by the revolutionary new Infinite Improbability Drive,
which lets the ship pass through every point in every universe simultaneously.
</p>
</Popover>
<a href="#" {...triggerProps}>
Trigger
</a>
</div>
);
}));

View File

@@ -0,0 +1,128 @@
/*
* 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, { Dispatch, FC, ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Action } from "./usePopover";
import styled from "styled-components";
type Props = {
title: ReactNode;
width?: number;
// props should be defined by usePopover
offsetTop?: number;
offsetLeft?: number;
show: boolean;
dispatch: Dispatch<Action>;
};
type ContainerProps = {
width: number;
};
const PopoverContainer = styled.div<ContainerProps>`
position: absolute;
z-index: 100;
width: ${props => props.width}px;
display: block;
&:before {
position: absolute;
content: "";
border-style: solid;
pointer-events: none;
height: 0;
width: 0;
top: 100%;
left: ${props => props.width / 2}px;
border-color: transparent;
border-bottom-color: white;
border-left-color: white;
border-width: 0.4rem;
margin-left: -0.4rem;
margin-top: -0.4rem;
-webkit-transform-origin: center;
transform-origin: center;
transform: rotate(-45deg);
}
`;
const SmallHr = styled.hr`
margin: 0.5em 0;
`;
const PopoverHeading = styled.div`
height: 1.5em;
`;
const Popover: FC<Props> = props => {
if (!props.show) {
return null;
}
return <InnerPopover {...props} />;
};
const InnerPopover: FC<Props> = ({ title, show, width, offsetTop, offsetLeft, dispatch, children }) => {
const [height, setHeight] = useState(125);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) {
setHeight(ref.current.clientHeight);
}
}, [ref]);
const onMouseEnter = () => {
dispatch({
type: "enter-popover"
});
};
const onMouseLeave = () => {
dispatch({
type: "leave-popover"
});
};
const top = (offsetTop || 0) - height - 5;
const left = (offsetLeft || 0) - width! / 2;
return (
<PopoverContainer
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={"box"}
style={{ top: `${top}px`, left: `${left}px` }}
width={width!}
ref={ref}
>
<PopoverHeading>{title}</PopoverHeading>
<SmallHr />
{children}
</PopoverContainer>
);
};
Popover.defaultProps = {
width: 120
};
export default Popover;

View File

@@ -0,0 +1,26 @@
/*
* 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.
*/
export { default as Popover } from "./Popover";
export { default as usePopover } from "./usePopover";

View File

@@ -0,0 +1,137 @@
/*
* 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 { Dispatch, useReducer, useRef } from "react";
type EnterTrigger = {
type: "enter-trigger";
offsetTop: number;
offsetLeft: number;
};
type LeaveTrigger = {
type: "leave-trigger";
};
type EnterPopover = {
type: "enter-popover";
};
type LeavePopover = {
type: "leave-popover";
};
export type Action = EnterTrigger | LeaveTrigger | EnterPopover | LeavePopover;
type State = {
offsetTop?: number;
offsetLeft?: number;
onPopover: boolean;
onTrigger: boolean;
};
const initialState = {
onPopover: false,
onTrigger: false
};
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "enter-trigger": {
if (state.onPopover) {
return state;
}
return {
offsetTop: action.offsetTop,
offsetLeft: action.offsetLeft,
onTrigger: true,
onPopover: false
};
}
case "leave-trigger": {
if (state.onPopover) {
return {
...state,
onTrigger: false
};
}
return initialState;
}
case "enter-popover": {
return {
...state,
onPopover: true
};
}
case "leave-popover": {
if (state.onTrigger) {
return {
...state,
onPopover: false
};
}
return initialState;
}
}
};
const dispatchDeferred = (dispatch: Dispatch<Action>, action: Action) => {
setTimeout(() => dispatch(action), 250);
};
const usePopover = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const triggerRef = useRef<HTMLElement | null>(null);
const onMouseOver = () => {
const current = triggerRef.current!;
dispatchDeferred(dispatch, {
type: "enter-trigger",
offsetTop: current.offsetTop,
offsetLeft: current.offsetLeft + current.offsetWidth / 2
});
};
const onMouseLeave = () => {
dispatchDeferred(dispatch, {
type: "leave-trigger"
});
};
return {
triggerProps: {
onMouseOver,
onMouseLeave,
ref: (node: HTMLElement | null) => (triggerRef.current = node)
},
popoverProps: {
dispatch,
show: state.onPopover || state.onTrigger,
offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft
}
};
};
export default usePopover;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useEffect, useState } from "react";
import React, { ReactNode, useEffect, useState } from "react";
import { storiesOf } from "@storybook/react";
import Diff from "./Diff";
// @ts-ignore
@@ -29,11 +29,15 @@ import parser from "gitdiff-parser";
import simpleDiff from "../__resources__/Diff.simple";
import hunksDiff from "../__resources__/Diff.hunks";
import binaryDiff from "../__resources__/Diff.binary";
import { DiffEventContext, File } from "./DiffTypes";
import { DiffEventContext, File, FileControlFactory } from "./DiffTypes";
import Toast from "../toast/Toast";
import { getPath } from "./diffs";
import DiffButton from "./DiffButton";
import styled from "styled-components";
import { MemoryRouter } from "react-router-dom";
import { one, two } from "../__resources__/changesets";
import { Changeset } from "@scm-manager/ui-types";
import JumpToFileButton from "./JumpToFileButton";
const diffFiles = parser.parse(simpleDiff);
@@ -41,11 +45,46 @@ const Container = styled.div`
padding: 2rem 6rem;
`;
const RoutingDecorator = (story: () => ReactNode) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>;
const fileControlFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const baseUrl = "/repo/hitchhiker/heartOfGold/code/changeset";
const sourceLink = {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: "Jump to source"
};
const targetLink = changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: "Jump to target"
};
const links = [];
switch (file.type) {
case "add":
links.push(sourceLink);
break;
case "delete":
if (targetLink) {
links.push(targetLink);
}
break;
default:
if (targetLink) {
links.push(sourceLink, targetLink);
} else {
links.push(sourceLink);
}
}
return links.map(({ url, label }) => <JumpToFileButton tooltip={label} link={url} />);
};
storiesOf("Diff", module)
.addDecorator(RoutingDecorator)
.addDecorator(storyFn => <Container>{storyFn()}</Container>)
.add("Default", () => <Diff diff={diffFiles} />)
.add("Side-By-Side", () => <Diff diff={diffFiles} sideBySide={true} />)
.add("Collapsed", () => <Diff diff={diffFiles} defaultCollapse={true} />)
.add("Collapsed", () => <Diff diff={diffFiles} defaultCollapse={true} fileControlFactory={fileControlFactory(two)} />)
.add("File Controls", () => (
<Diff
diff={diffFiles}
@@ -121,4 +160,5 @@ storiesOf("Diff", module)
return file;
});
return <Diff diff={filesWithLanguage} />;
});
})
.add("WithLinkToFile", () => <Diff diff={diffFiles} />);

View File

@@ -23,13 +23,14 @@
*/
import React from "react";
import DiffFile from "./DiffFile";
import { DiffObjectProps, File } from "./DiffTypes";
import { DiffObjectProps, File, FileControlFactory } from "./DiffTypes";
import Notification from "../Notification";
import { WithTranslation, withTranslation } from "react-i18next";
type Props = WithTranslation &
DiffObjectProps & {
diff: File[];
fileControlFactory?: FileControlFactory;
};
class Diff extends React.Component<Props> {

View File

@@ -309,7 +309,11 @@ class DiffFile extends React.Component<Props, State> {
if (file._links?.lines) {
items.push(this.createHunkHeader(expandableHunk));
} else if (i > 0) {
items.push(<Decoration><HunkDivider /></Decoration>);
items.push(
<Decoration>
<HunkDivider />
</Decoration>
);
}
items.push(
@@ -411,29 +415,31 @@ class DiffFile extends React.Component<Props, State> {
}
const collapseIcon = this.hasContent(file) ? <Icon name={icon} color="inherit" /> : null;
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
const sideBySideToggle =
file.hunks && file.hunks.length > 0 ? (
<ButtonWrapper className={classNames("level-right", "is-flex")}>
<ButtonGroup>
<MenuContext.Consumer>
{({ setCollapsed }) => (
<DiffButton
icon={sideBySide ? "align-left" : "columns"}
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
onClick={() =>
this.toggleSideBySide(() => {
if (this.state.sideBySide) {
setCollapsed(true);
}
})
}
/>
)}
</MenuContext.Consumer>
{fileControls}
</ButtonGroup>
</ButtonWrapper>
) : null;
const sideBySideToggle = file.hunks && file.hunks.length && (
<MenuContext.Consumer>
{({ setCollapsed }) => (
<DiffButton
icon={sideBySide ? "align-left" : "columns"}
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
onClick={() =>
this.toggleSideBySide(() => {
if (this.state.sideBySide) {
setCollapsed(true);
}
})
}
/>
)}
</MenuContext.Consumer>
);
const headerButtons = (
<ButtonWrapper className={classNames("level-right", "is-flex")}>
<ButtonGroup>
{sideBySideToggle}
{fileControls}
</ButtonGroup>
</ButtonWrapper>
);
let errorModal;
if (expansionError) {
@@ -463,7 +469,7 @@ class DiffFile extends React.Component<Props, State> {
</TitleWrapper>
{this.renderChangeTag(file)}
</FullWidthTitleHeader>
{sideBySideToggle}
{headerButtons}
</FlexWrapLevel>
</div>
{body}

View File

@@ -0,0 +1,54 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Tooltip from "../Tooltip";
import Icon from "../Icon";
const Button = styled(Link)`
width: 50px;
cursor: pointer;
&:hover {
color: #33b2e8;
}
`;
type Props = {
link: string;
tooltip: string;
};
const JumpToFileButton: FC<Props> = ({ link, tooltip }) => {
return (
<Tooltip message={tooltip} location="top">
<Button aria-label={tooltip} className="button" to={link}>
<Icon name="file-code" color="inherit" />
</Button>
</Tooltip>
);
};
export default JumpToFileButton;

View File

@@ -22,14 +22,16 @@
* SOFTWARE.
*/
import React from "react";
import { Changeset, Link, Collection } from "@scm-manager/ui-types";
import { Changeset, Collection, Link } from "@scm-manager/ui-types";
import LoadingDiff from "../LoadingDiff";
import Notification from "../../Notification";
import { WithTranslation, withTranslation } from "react-i18next";
import { FileControlFactory } from "../DiffTypes";
type Props = WithTranslation & {
changeset: Changeset;
defaultCollapse?: boolean;
fileControlFactory?: FileControlFactory;
};
export const isDiffSupported = (changeset: Collection) => {
@@ -47,12 +49,19 @@ export const createUrl = (changeset: Collection) => {
class ChangesetDiff extends React.Component<Props> {
render() {
const { changeset, defaultCollapse, t } = this.props;
const { changeset, fileControlFactory, defaultCollapse, t } = this.props;
if (!isDiffSupported(changeset)) {
return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>;
} else {
const url = createUrl(changeset);
return <LoadingDiff url={url} defaultCollapse={defaultCollapse} sideBySide={false} />;
return (
<LoadingDiff
url={url}
defaultCollapse={defaultCollapse}
sideBySide={false}
fileControlFactory={fileControlFactory}
/>
);
}
}
}

View File

@@ -35,6 +35,7 @@ import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTags from "./ChangesetTags";
import ChangesetButtonGroup from "./ChangesetButtonGroup";
import ChangesetDescription from "./ChangesetDescription";
import SignatureIcon from "./SignatureIcon";
type Props = WithTranslation & {
repository: Repository;
@@ -79,6 +80,11 @@ const VCenteredChildColumn = styled.div`
justify-content: flex-end;
`;
const FlexRow = styled.div`
display: flex;
flex-direction: row;
`;
class ChangesetRow extends React.Component<Props> {
createChangesetId = (changeset: Changeset) => {
const { repository } = this.props;
@@ -101,7 +107,7 @@ class ChangesetRow extends React.Component<Props> {
<AvatarWrapper>
<AvatarFigure className="media-left">
<FixedSizedAvatar className="image">
<AvatarImage person={changeset.author}/>
<AvatarImage person={changeset.author} />
</FixedSizedAvatar>
</AvatarFigure>
</AvatarWrapper>
@@ -119,24 +125,32 @@ class ChangesetRow extends React.Component<Props> {
</ExtensionPoint>
</h4>
<p className="is-hidden-touch">
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]}/>
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]} />
</p>
<p className="is-hidden-desktop">
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]}/>
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]} />
</p>
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
<ChangesetAuthor changeset={changeset}/>
</AuthorWrapper>
<FlexRow>
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
<ChangesetAuthor changeset={changeset} />
</AuthorWrapper>
{changeset?.signatures && changeset.signatures.length > 0 && (
<SignatureIcon
className="mx-2 pt-1"
signatures={changeset.signatures}
/>
)}
</FlexRow>
</Metadata>
</div>
</div>
<VCenteredColumn className="column">
<ChangesetTags changeset={changeset}/>
<ChangesetTags changeset={changeset} />
</VCenteredColumn>
</div>
</div>
<VCenteredChildColumn className={classNames("column", "is-flex")}>
<ChangesetButtonGroup repository={repository} changeset={changeset}/>
<ChangesetButtonGroup repository={repository} changeset={changeset} />
<ExtensionPoint
name="changeset.right"
props={{

View File

@@ -22,22 +22,22 @@
* SOFTWARE.
*/
import { storiesOf } from "@storybook/react";
import {storiesOf} from "@storybook/react";
import * as React from "react";
import styled from "styled-components";
import { MemoryRouter } from "react-router-dom";
import {MemoryRouter} from "react-router-dom";
import repository from "../../__resources__/repository";
import ChangesetRow from "./ChangesetRow";
import { one, two, three, four, five } from "../../__resources__/changesets";
import { Binder, BinderContext } from "@scm-manager/ui-extensions";
import {one, two, three, four, five} from "../../__resources__/changesets";
import {Binder, BinderContext} from "@scm-manager/ui-extensions";
// @ts-ignore
import hitchhiker from "../../__resources__/hitchhiker.png";
import { Person } from "../../avatar/Avatar";
import { Changeset } from "@scm-manager/ui-types";
import { Replacement } from "../../SplitAndReplace";
import {Person} from "../../avatar/Avatar";
import {Changeset} from "@scm-manager/ui-types";
import {Replacement} from "../../SplitAndReplace";
const Wrapper = styled.div`
margin: 2rem;
margin: 25rem 4rem;
`;
const robohash = (person: Person) => {
@@ -49,7 +49,7 @@ const withAvatarFactory = (factory: (person: Person) => string, changeset: Chang
binder.bind("avatar.factory", factory);
return (
<BinderContext.Provider value={binder}>
<ChangesetRow repository={repository} changeset={changeset} />
<ChangesetRow repository={repository} changeset={changeset}/>
</BinderContext.Provider>
);
};
@@ -62,18 +62,22 @@ const withReplacements = (
replacements.forEach(replacement => binder.bind("changeset.description.tokens", replacement));
return (
<BinderContext.Provider value={binder}>
<ChangesetRow repository={repository} changeset={changeset} />
<ChangesetRow repository={repository} changeset={changeset}/>
</BinderContext.Provider>
);
};
function copy<T>(input: T): T {
return JSON.parse(JSON.stringify(input));
}
storiesOf("Changesets", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator(storyFn => <Wrapper className="box box-link-shadow">{storyFn()}</Wrapper>)
.add("Default", () => <ChangesetRow repository={repository} changeset={three} />)
.add("With Committer", () => <ChangesetRow repository={repository} changeset={two} />)
.add("With Committer and Co-Author", () => <ChangesetRow repository={repository} changeset={one} />)
.add("With multiple Co-Authors", () => <ChangesetRow repository={repository} changeset={four} />)
.add("Default", () => <ChangesetRow repository={repository} changeset={three}/>)
.add("With Committer", () => <ChangesetRow repository={repository} changeset={two}/>)
.add("With Committer and Co-Author", () => <ChangesetRow repository={repository} changeset={one}/>)
.add("With multiple Co-Authors", () => <ChangesetRow repository={repository} changeset={four}/>)
.add("With avatar", () => {
return withAvatarFactory(() => hitchhiker, three);
})
@@ -88,9 +92,156 @@ storiesOf("Changesets", module)
const mail = <a href={"mailto:hog@example.com"}>Arthur</a>;
return withReplacements(
[
() => [{ textToReplace: "HOG-42", replacement: link }],
() => [{ textToReplace: "arthur@guide.galaxy", replacement: mail }]
() => [{textToReplace: "HOG-42", replacement: link}],
() => [{textToReplace: "arthur@guide.galaxy", replacement: mail}]
],
five
);
})
.add("With unknown signature", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "NOT_FOUND"
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With valid signature", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "VERIFIED",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With unowned signature", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "VERIFIED",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With contactless signature", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "VERIFIED",
owner: "trillian"
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With invalid signature", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "INVALID",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With multiple signatures and invalid status", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x912389FJIQW8W223",
type: "gpg",
status: "INVALID",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}, {
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "VERIFIED",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}, {
keyId: "0x9123891239VFIA33",
type: "gpg",
status: "NOT_FOUND",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With multiple signatures and valid status", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x912389FJIQW8W223",
type: "gpg",
status: "NOT_FOUND",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}, {
keyId: "0x247E908C6FD35473",
type: "gpg",
status: "VERIFIED",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}, {
keyId: "0x9123891239VFIA33",
type: "gpg",
status: "NOT_FOUND",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
})
.add("With multiple signatures and not found status", () => {
const changeset = copy(three);
changeset.signatures = [{
keyId: "0x912389FJIQW8W223",
type: "gpg",
status: "NOT_FOUND",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}, {
keyId: "0x9123891239VFIA33",
type: "gpg",
status: "NOT_FOUND",
owner: "trillian",
contacts: [{
name: "Tricia Marie McMilla",
mail: "trillian@hitchhiker.com"
}]
}];
return <ChangesetRow repository={repository} changeset={changeset}/>;
});

View File

@@ -0,0 +1,119 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, {FC} from "react";
import {useTranslation} from "react-i18next";
import {Signature} from "@scm-manager/ui-types";
import styled from "styled-components";
import Icon from "../../Icon";
import {usePopover} from "../../popover";
import Popover from "../../popover/Popover";
import classNames from "classnames";
type Props = {
signatures: Signature[];
className: any;
};
const StyledIcon = styled(Icon)`
width: 1em;
height: 1em;
vertical-align: middle;
border-radius: 0.25em;
margin-bottom: 0.2em;
`;
const StyledDiv = styled.div`
> *:not(:last-child) {
margin-bottom: 24px;
}
`;
const SignatureIcon: FC<Props> = ({signatures, className}) => {
const [t] = useTranslation("repos");
const {popoverProps, triggerProps} = usePopover();
if (!signatures.length) {
return null;
}
const getColor = (signaturesToVerify: Signature[]) => {
const invalid = signaturesToVerify.some(sig => sig.status === "INVALID");
if (invalid) {
return "danger";
}
const verified = signaturesToVerify.some(sig => sig.status === "VERIFIED");
if (verified) {
return "success";
}
return undefined;
};
const createSignatureBlock = (signature: Signature) => {
let status;
if (signature.status === "VERIFIED") {
status = t("changeset.signatureVerified");
} else if (signature.status === "INVALID") {
status = t("changeset.signatureInvalid");
} else {
status = t("changeset.signatureNotVerified");
}
if (signature.status === "NOT_FOUND") {
return <p>
<div>{t("changeset.keyId")}: {signature.keyId}</div>
<div>{t("changeset.signatureStatus")}: {status}</div>
</p>;
}
return <p>
<div>{t("changeset.keyId")}: {
signature._links?.rawKey ? <a href={signature._links.rawKey.href}>{signature.keyId}</a> : signature.keyId
}</div>
<div>{t("changeset.signatureStatus")}: <span className={classNames(`has-text-${getColor([signature])}`)}>{status}</span></div>
<div>{t("changeset.keyOwner")}: {signature.owner || t("changeset.noOwner")}</div>
{signature.contacts && signature.contacts.length > 0 && <>
<div>{t("changeset.keyContacts")}:</div>
{signature.contacts && signature.contacts.map(contact =>
<div>- {contact.name}{contact.mail && ` <${contact.mail}>`}</div>)}
</>}
</p>;
};
const signatureElements = signatures.map(signature => createSignatureBlock(signature));
return (
<>
<Popover title={<h1 className="has-text-weight-bold is-size-5">{t("changeset.signatures")}</h1>} width={500} {...popoverProps}>
<StyledDiv>
{signatureElements}
</StyledDiv>
</Popover>
<div {...triggerProps}>
<StyledIcon name="key" className={className} color={getColor(signatures)}/>
</div>
</>
);
};
export default SignatureIcon;

View File

@@ -36,3 +36,4 @@ export { default as ChangesetTag } from "./ChangesetTag";
export { default as ChangesetTags } from "./ChangesetTags";
export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed";
export { default as ContributorAvatar } from "./ContributorAvatar";
export { default as SignatureIcon } from "./SignatureIcon";

View File

@@ -45,11 +45,13 @@ export * from "./changesets";
export { default as Diff } from "./Diff";
export { default as DiffFile } from "./DiffFile";
export { default as DiffButton } from "./DiffButton";
export { FileControlFactory } from "./DiffTypes";
export { default as LoadingDiff } from "./LoadingDiff";
export { DefaultCollapsed, DefaultCollapsedFunction } from "./defaultCollapsed";
export { default as RepositoryAvatar } from "./RepositoryAvatar";
export { default as RepositoryEntry } from "./RepositoryEntry";
export { default as RepositoryEntryLink } from "./RepositoryEntryLink";
export { default as JumpToFileButton } from "./JumpToFileButton";
export {
File,