mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-12-22 16:29:51 +01:00
Merge branch 'develop' into bugfix/markdown_view_anchor_links
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
94
scm-ui/ui-components/src/devBuild.test.ts
Normal file
94
scm-ui/ui-components/src/devBuild.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
42
scm-ui/ui-components/src/devBuild.ts
Normal file
42
scm-ui/ui-components/src/devBuild.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
73
scm-ui/ui-components/src/popover/Popover.stories.tsx
Normal file
73
scm-ui/ui-components/src/popover/Popover.stories.tsx
Normal 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>
|
||||
);
|
||||
}));
|
||||
128
scm-ui/ui-components/src/popover/Popover.tsx
Normal file
128
scm-ui/ui-components/src/popover/Popover.tsx
Normal 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;
|
||||
26
scm-ui/ui-components/src/popover/index.ts
Normal file
26
scm-ui/ui-components/src/popover/index.ts
Normal 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";
|
||||
137
scm-ui/ui-components/src/popover/usePopover.ts
Normal file
137
scm-ui/ui-components/src/popover/usePopover.ts
Normal 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;
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
54
scm-ui/ui-components/src/repos/JumpToFileButton.tsx
Normal file
54
scm-ui/ui-components/src/repos/JumpToFileButton.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}/>;
|
||||
});
|
||||
|
||||
119
scm-ui/ui-components/src/repos/changesets/SignatureIcon.tsx
Normal file
119
scm-ui/ui-components/src/repos/changesets/SignatureIcon.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user