Merge pull request #1231 from scm-manager/feature/changeset_short_link

Changeset short link
This commit is contained in:
Sebastian Sdorra
2020-07-02 12:17:16 +02:00
committed by GitHub
19 changed files with 746 additions and 37 deletions

View File

@@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Rename repository name (and namespace if permitted) ([#1218](https://github.com/scm-manager/scm-manager/pull/1218))
- enrich commit mentions in markdown viewer by internal links ([#1210](https://github.com/scm-manager/scm-manager/pull/1210))
- New extension point `changeset.description.tokens` to "enrich" commit messages ([#1231](https://github.com/scm-manager/scm-manager/pull/1231))
- restart service after rpm or deb package upgrade
### Changed
- Checkboxes can now be 'indeterminate' ([#1215](https://github.com/scm-manager/scm-manager/pull/1215))
- The old frontend extension point `changeset.description` is deprecated and should be replaced with `changeset.description.tokens` ([#1231](https://github.com/scm-manager/scm-manager/pull/1231))
### Fixed
- Fixed installation of debian packages on distros without preinstalled `at` ([#1216](https://github.com/scm-manager/scm-manager/issues/1216) and [#1217](https://github.com/scm-manager/scm-manager/pull/1217))

View File

@@ -7,7 +7,12 @@ The following extension points are provided for the frontend:
### admin.navigation
### admin.route
### admin.setting
### changeset.description
### changeset.description.tokens
- Can be used to replace parts of a changeset description with components
- Has to be bound with a funktion taking the changeset and the (partial) description and returning `Replacement` objects with the following attributes:
- textToReplace: The text part of the description that should be replaced by a component
- replacement: The component to take instead of the text to replace
- replaceAll: Optional boolean; if set to `true`, all occurances of the text will be replaced (default: `false`)
### changeset.right
### changesets.author.suffix
### group.navigation
@@ -56,6 +61,11 @@ The following extension points are provided for the frontend:
# Deprecated
### changeset.description
- can be used to replace the whole description of a changeset
**Deprecated:** Use `changeset.description.tokens` instead
### changeset.avatar-factory
- Location: At every changeset (detailed view as well as changeset overview)
- can be used to add avatar (such as gravatar) for each changeset
@@ -72,7 +82,7 @@ The following extension points are provided for the frontend:
### markdown-renderer-factory
- A Factory function to create markdown [renderer](https://github.com/rexxars/react-markdown#node-types)
- The factory function will be called with a renderContext parameter of type Object. this parameter is given as a prop for the MarkdownView component.
**example:**

View File

@@ -32,7 +32,7 @@ import MarkdownHeadingRenderer from "./MarkdownHeadingRenderer";
import { create } from "./MarkdownLinkRenderer";
import {useTranslation, WithTranslation, withTranslation} from "react-i18next";
import Notification from "./Notification";
import { createTransformer } from "./remarkCommitLinksParser";
import { createTransformer } from "./remarkChangesetShortLinkParser";
type Props = RouteComponentProps & WithTranslation & {
content: string;

View File

@@ -0,0 +1,57 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { storiesOf } from "@storybook/react";
import SplitAndReplace from "./SplitAndReplace";
import { Icon } from "@scm-manager/ui-components";
import styled from "styled-components";
const Wrapper = styled.div`
margin: 2rem;
`;
storiesOf("SplitAndReplace", module).add("Simple replacement", () => {
const replacements = [
{
textToReplace: "'",
replacement: <Icon name={"quote-left"} />,
replaceAll: true
},
{
textToReplace: "`",
replacement: <Icon name={"quote-right"} />,
replaceAll: true
}
];
return (
<>
<Wrapper>
<SplitAndReplace text={"'So this is it,` said Arthur, 'We are going to die.`"} replacements={replacements} />
</Wrapper>
<Wrapper>
<SplitAndReplace text={"'Yes,` said Ford, 'except... no! Wait a minute!`"} replacements={replacements} />
</Wrapper>
</>
);
});

View File

@@ -0,0 +1,58 @@
/*
* 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, ReactNode } from "react";
import textSplitAndReplace from "./textSplitAndReplace";
export type Replacement = {
textToReplace: string;
replacement: ReactNode;
replaceAll?: boolean;
};
type Props = {
text: string;
replacements: Replacement[];
};
const textWrapper = (s: string) => {
const first = s.startsWith(" ") ? <>&nbsp;</> : "";
const last = s.endsWith(" ") ? <>&nbsp;</> : "";
return (
<>
{first}
{s}
{last}
</>
);
};
const SplitAndReplace: FC<Props> = ({ text, replacements }) => {
const parts = textSplitAndReplace<ReactNode>(text, replacements, textWrapper);
if (parts.length === 0) {
return <>{parts[0]}</>;
}
return <>{parts}</>;
};
export default SplitAndReplace;

View File

@@ -218,6 +218,54 @@ const four: Changeset = {
}
};
const five: Changeset = {
id: "d21cc6c359270aef2196796f4d96af65f51866dc",
author: { mail: "scm-admin@scm-manager.org", name: "SCM Administrator" },
date: new Date("2020-06-09T05:39:50Z"),
description: "HOG-42 Change mail to arthur@guide.galaxy\n\n",
_links: {
self: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/d21cc6c359270aef2196796f4d96af65f51866dc"
},
diff: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/d21cc6c359270aef2196796f4d96af65f51866dc"
},
sources: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/sources/d21cc6c359270aef2196796f4d96af65f51866dc"
},
modifications: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/modifications/d21cc6c359270aef2196796f4d96af65f51866dc"
},
diffParsed: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/d21cc6c359270aef2196796f4d96af65f51866dc/parsed"
}
},
_embedded: {
tags: [],
branches: [],
parents: [
{
id: "e163c8f632db571c9aa51a8eb440e37cf550b825",
_links: {
self: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/e163c8f632db571c9aa51a8eb440e37cf550b825"
},
diff: {
href:
"http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/e163c8f632db571c9aa51a8eb440e37cf550b825"
}
}
}
]
}
};
const changesets: PagedCollection = {
page: 0,
pageTotal: 1,
@@ -246,5 +294,5 @@ const changesets: PagedCollection = {
}
};
export { one, two, three, four };
export { one, two, three, four, five };
export default changesets;

View File

@@ -1755,7 +1755,9 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
Added design docs
</h4>
<p
className="is-hidden-touch"
@@ -1919,7 +1921,9 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive. The craft was stolen by then-President Zaphod Beeblebrox at the official launch of the ship, as he was supposed to be officiating the launch. Later, during the use of the Infinite Improbability Drive, the ship picked up Arthur Dent and Ford Prefect, who were floating unprotected in deep space in the same star sector, having just escaped the destruction of the same planet.
</h4>
<p
className="is-hidden-touch"
@@ -2059,7 +2063,132 @@ exports[`Storyshots Changesets Default 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
initialize repository
</h4>
<p
className="is-hidden-touch"
/>
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
</p>
</div>
</div>
</div>
<div
className="ChangesetRow__VCenteredColumn-tkpti5-5 jtvbjX column"
/>
</div>
</div>
<div
className="ChangesetRow__VCenteredChildColumn-tkpti5-6 cciHUW column is-flex"
>
<div
className="ButtonAddons__Flex-sc-182golj-0 jSuMVB field has-addons is-marginless"
>
<p
className="control"
>
<button
className="button is-default is-reduced-mobile"
onClick={[Function]}
type="button"
>
<span
className="icon is-medium"
>
<i
className="fas fa-exchange-alt has-text-inherit"
/>
</span>
<span>
changeset.buttons.details
</span>
</button>
</p>
<p
className="control"
>
<button
className="button is-default is-reduced-mobile"
onClick={[Function]}
type="button"
>
<span
className="icon is-medium"
>
<i
className="fas fa-code has-text-inherit"
/>
</span>
<span>
changeset.buttons.sources
</span>
</button>
</p>
</div>
</div>
</div>
</div>
</div>
`;
exports[`Storyshots Changesets Replacements 1`] = `
<div
className="Changesetsstories__Wrapper-sc-122npan-0 hHKBXk box box-link-shadow"
>
<div
className="ChangesetRow__Wrapper-tkpti5-0 bRWdJS"
>
<div
className="columns is-gapless is-mobile"
>
<div
className="column is-three-fifths"
>
<div
className="columns is-gapless"
>
<div
className="column is-four-fifths"
>
<div
className="media"
>
<div
className="ChangesetRow__Metadata-tkpti5-3 dfKqLe media-right"
>
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
<a
href="http://example.com/hog"
>
HOG-42
</a>
 
Change mail to
 
<a
href="mailto:hog@example.com"
>
Arthur
</a>
</h4>
<p
className="is-hidden-touch"
@@ -2170,7 +2299,9 @@ exports[`Storyshots Changesets With Committer 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
Change heading to "Heart Of Gold"
</h4>
<p
className="is-hidden-touch"
@@ -2293,7 +2424,9 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive. The craft was stolen by then-President Zaphod Beeblebrox at the official launch of the ship, as he was supposed to be officiating the launch. Later, during the use of the Infinite Improbability Drive, the ship picked up Arthur Dent and Ford Prefect, who were floating unprotected in deep space in the same star sector, having just escaped the destruction of the same planet.
</h4>
<p
className="is-hidden-touch"
@@ -2438,7 +2571,9 @@ exports[`Storyshots Changesets With avatar 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
initialize repository
</h4>
<p
className="is-hidden-touch"
@@ -2549,7 +2684,9 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = `
<h4
className="has-text-weight-bold is-ellipsis-overflow"
>
Added design docs
</h4>
<p
className="is-hidden-touch"
@@ -41699,6 +41836,61 @@ exports[`Storyshots RepositoryEntry Quick Link EP 1`] = `
</div>
`;
exports[`Storyshots SplitAndReplace Simple replacement 1`] = `
Array [
<div
className="SplitAndReplacestories__Wrapper-sc-1bo2tpl-0 bmWfkY"
>
<i
className="fas fa-quote-left has-text-grey-light"
/>
So this is it,
<i
className="fas fa-quote-right has-text-grey-light"
/>
 
said Arthur,
 
<i
className="fas fa-quote-left has-text-grey-light"
/>
We are going to die.
<i
className="fas fa-quote-right has-text-grey-light"
/>
</div>,
<div
className="SplitAndReplacestories__Wrapper-sc-1bo2tpl-0 bmWfkY"
>
<i
className="fas fa-quote-left has-text-grey-light"
/>
Yes,
<i
className="fas fa-quote-right has-text-grey-light"
/>
 
said Ford,
 
<i
className="fas fa-quote-left has-text-grey-light"
/>
except... no! Wait a minute!
<i
className="fas fa-quote-right has-text-grey-light"
/>
</div>,
]
`;
exports[`Storyshots SyntaxHighlighter Go 1`] = `
<div
className="SyntaxHighlighterstories__Spacing-sc-1dcldp5-0 kdZjDS"

View File

@@ -78,6 +78,8 @@ export { default as CardColumnGroup } from "./CardColumnGroup";
export { default as CardColumn } from "./CardColumn";
export { default as CardColumnSmall } from "./CardColumnSmall";
export { default as CommaSeparatedList } from "./CommaSeparatedList";
export { default as SplitAndReplace, Replacement } from "./SplitAndReplace";
export { regExpPattern as changesetShortLinkRegex } from "./remarkChangesetShortLinkParser";
export { default as comparators } from "./comparators";

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { regExpPattern } from "./remarkCommitLinksParser";
import { regExpPattern } from "./remarkChangesetShortLinkParser";
describe("Remark Commit Links RegEx Tests", () => {
it("should match simple names", () => {
@@ -58,8 +58,6 @@ describe("Remark Commit Links RegEx Tests", () => {
match = regExp.exec(text);
}
console.log(matches)
expect(matches[0]).toBe("hitchhiker/heart-of-gold@42");
expect(matches[1]).toBe("hitchhiker/heart-of-gold@21");
});

View File

@@ -30,7 +30,6 @@ import { TFunction } from "i18next";
const namePartRegex = nameRegex.source.substring(1, nameRegex.source.length - 1);
// Visible for testing
export const regExpPattern = `(${namePartRegex})\\/(${namePartRegex})@([\\w\\d]+)`;
function match(value: string): RegExpMatchArray[] {
@@ -45,7 +44,6 @@ function match(value: string): RegExpMatchArray[] {
}
export const createTransformer = (t: TFunction): MdastPlugin => {
return (tree: MarkdownAbstractSyntaxTree) => {
visit(tree, "text", (node: MarkdownAbstractSyntaxTree, index: number, parent: MarkdownAbstractSyntaxTree) => {
if (parent.type === "link" || !node.value) {

View File

@@ -0,0 +1,49 @@
/*
* 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 { Changeset } from "@scm-manager/ui-types";
import { useBinder } from "@scm-manager/ui-extensions";
import { SplitAndReplace, Replacement } from "@scm-manager/ui-components";
type Props = {
changeset: Changeset;
value: string;
};
const ChangesetDescription: FC<Props> = ({ changeset, value }) => {
const binder = useBinder();
const replacements: ((changeset: Changeset, value: string) => Replacement[])[] = binder.getExtensions(
"changeset.description.tokens",
{
changeset,
value
}
);
return <SplitAndReplace text={value} replacements={replacements.flatMap(r => r(changeset, value))} />;
};
export default ChangesetDescription;

View File

@@ -34,6 +34,7 @@ import ChangesetId from "./ChangesetId";
import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTags from "./ChangesetTags";
import ChangesetButtonGroup from "./ChangesetButtonGroup";
import ChangesetDescription from "./ChangesetDescription";
type Props = WithTranslation & {
repository: Repository;
@@ -100,7 +101,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>
@@ -114,28 +115,28 @@ class ChangesetRow extends React.Component<Props> {
}}
renderAll={false}
>
{description.title}
<ChangesetDescription changeset={changeset} value={description.title} />
</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} />
<ChangesetAuthor changeset={changeset}/>
</AuthorWrapper>
</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

@@ -28,12 +28,13 @@ import styled from "styled-components";
import { MemoryRouter } from "react-router-dom";
import repository from "../../__resources__/repository";
import ChangesetRow from "./ChangesetRow";
import {one, two, three, four} 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/src";
import { Person } from "../../avatar/Avatar";
import { Changeset } from "@scm-manager/ui-types";
import { Replacement } from "../../SplitAndReplace";
const Wrapper = styled.div`
margin: 2rem;
@@ -41,7 +42,7 @@ const Wrapper = styled.div`
const robohash = (person: Person) => {
return `https://robohash.org/${person.mail}`;
}
};
const withAvatarFactory = (factory: (person: Person) => string, changeset: Changeset) => {
const binder = new Binder("changeset stories");
@@ -53,27 +54,43 @@ const withAvatarFactory = (factory: (person: Person) => string, changeset: Chang
);
};
const withReplacements = (
replacements: ((changeset: Changeset, value: string) => Replacement[])[],
changeset: Changeset
) => {
const binder = new Binder("changeset stories");
replacements.forEach(replacement => binder.bind("changeset.description.tokens", replacement));
return (
<BinderContext.Provider value={binder}>
<ChangesetRow repository={repository} changeset={changeset} />
</BinderContext.Provider>
);
};
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(person => hitchhiker, three);
return withAvatarFactory(() => hitchhiker, three);
})
.add("Commiter and Co-Authors with avatar", () => {
return withAvatarFactory(robohash, one);
})
.add("Co-Authors with avatar", () => {
return withAvatarFactory(robohash, four);
})
.add("Replacements", () => {
const link = <a href={"http://example.com/hog"}>HOG-42</a>;
const mail = <a href={"mailto:hog@example.com"}>Arthur</a>;
return withReplacements(
[
() => [{ textToReplace: "HOG-42", replacement: link }],
() => [{ textToReplace: "arthur@guide.galaxy", replacement: mail }]
],
five
);
});

View File

@@ -27,6 +27,7 @@ export { changesets };
export { default as ChangesetAuthor, SingleContributor } from "./ChangesetAuthor";
export { default as ChangesetButtonGroup } from "./ChangesetButtonGroup";
export { default as ChangesetDescription } from "./ChangesetDescription";
export { default as ChangesetDiff } from "./ChangesetDiff";
export { default as ChangesetId } from "./ChangesetId";
export { default as ChangesetList } from "./ChangesetList";

View File

@@ -0,0 +1,134 @@
/*
* 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 textSplitAndReplace from "./textSplitAndReplace";
type Wrapped = {
text: string;
};
const testWrapper = (s: string) => {
return { text: s };
};
describe("text split and replace", () => {
it("should wrap text if nothing should be replaced", () => {
const result = textSplitAndReplace<Wrapped>("Don't Panic.", [], testWrapper);
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual({ text: "Don't Panic." });
});
it("should replace single string", () => {
const result = textSplitAndReplace<Wrapped>(
"Don't Panic.",
[{ textToReplace: "'", replacement: { text: "`" } }],
testWrapper
);
expect(result).toHaveLength(3);
expect(result[0]).toStrictEqual({ text: "Don" });
expect(result[1]).toStrictEqual({ text: "`" });
expect(result[2]).toStrictEqual({ text: "t Panic." });
});
it("should replace strings only once if replace all is not set", () => {
const result = textSplitAndReplace<Wrapped>(
"'So this is it,' said Arthur, 'We are going to die.'",
[{ textToReplace: "'", replacement: { text: "“" } }],
testWrapper
);
expect(result).toHaveLength(2);
expect(result[0]).toStrictEqual({ text: "“" });
expect(result[1]).toStrictEqual({ text: "So this is it,' said Arthur, 'We are going to die.'" });
});
it("should replace all strings if replace all is set to true", () => {
const result = textSplitAndReplace<Wrapped>(
"'So this is it,' said Arthur, 'We are going to die.'",
[{ textToReplace: "'", replacement: { text: "“" }, replaceAll: true }],
testWrapper
);
expect(result).toHaveLength(7);
expect(result[0]).toStrictEqual({ text: "“" });
expect(result[1]).toStrictEqual({ text: "So this is it," });
expect(result[2]).toStrictEqual({ text: "“" });
expect(result[3]).toStrictEqual({ text: " said Arthur, " });
expect(result[4]).toStrictEqual({ text: "“" });
expect(result[5]).toStrictEqual({ text: "We are going to die." });
expect(result[6]).toStrictEqual({ text: "“" });
});
it("should replace strings with multiple replacements", () => {
const result = textSplitAndReplace<Wrapped>(
"'So this is it,' said Arthur, 'We are going to die.'",
[
{ textToReplace: "'", replacement: { text: "“" }, replaceAll: true },
{ textToReplace: "Arthur", replacement: { text: "Dent" }, replaceAll: true }
],
testWrapper
);
expect(result).toHaveLength(9);
expect(result[0]).toStrictEqual({ text: "“" });
expect(result[1]).toStrictEqual({ text: "So this is it," });
expect(result[2]).toStrictEqual({ text: "“" });
expect(result[3]).toStrictEqual({ text: " said " });
expect(result[4]).toStrictEqual({ text: "Dent" });
expect(result[5]).toStrictEqual({ text: ", " });
expect(result[6]).toStrictEqual({ text: "“" });
expect(result[7]).toStrictEqual({ text: "We are going to die." });
expect(result[8]).toStrictEqual({ text: "“" });
});
it("should ignore conflicting replacements", () => {
const result = textSplitAndReplace<Wrapped>(
"'So this is it,' said Arthur, 'We are going to die.'",
[
{ textToReplace: "said Arthur", replacement: { text: "to be replaced" } },
{ textToReplace: " said", replacement: { text: "to be ignored 1" }, replaceAll: true },
{ textToReplace: "d A", replacement: { text: "to be ignored 2" }, replaceAll: true },
{ textToReplace: "Arthur,", replacement: { text: "to be ignored 3" }, replaceAll: true }
],
testWrapper
);
expect(result).toHaveLength(3);
expect(result[0]).toStrictEqual({ text: "'So this is it,' " });
expect(result[1]).toStrictEqual({ text: "to be replaced" });
expect(result[2]).toStrictEqual({ text: ", 'We are going to die.'" });
});
it("should replace adjacent texts", () => {
const result = textSplitAndReplace<Wrapped>(
"'So this is it,' said Arthur, 'We are going to die.'",
[
{ textToReplace: "'So this is it,'", replacement: { text: "one" } },
{ textToReplace: " said Arthur, ", replacement: { text: "two" } },
{ textToReplace: "'We are going to die.'", replacement: { text: "three" } }
],
testWrapper
);
expect(result).toHaveLength(3);
expect(result[0]).toStrictEqual({ text: "one" });
expect(result[1]).toStrictEqual({ text: "two" });
expect(result[2]).toStrictEqual({ text: "three" });
});
});

View File

@@ -0,0 +1,86 @@
/*
* 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.
*/
type Replacement<T> = {
textToReplace: string;
replacement: T;
replaceAll?: boolean;
};
type PartToReplace<T> = {
start: number;
length: number;
replacement: T;
};
function hasConflict<T>(alreadyFoundReplacements: PartToReplace<T>[], newReplacement: PartToReplace<T>) {
return !!alreadyFoundReplacements.find(
existing =>
(existing.start <= newReplacement.start && existing.start + existing.length > newReplacement.start) ||
(newReplacement.start <= existing.start && newReplacement.start + newReplacement.length > existing.start)
);
}
export default function textSplitAndReplace<T>(
text: string,
replacements: Replacement<T>[],
textWrapper: (s: string) => T
): T[] {
const partsToReplace: PartToReplace<T>[] = [];
replacements.forEach(replacement => {
let lastIndex = -1;
do {
const start = text.indexOf(replacement.textToReplace, lastIndex);
if (start >= 0) {
const length = replacement.textToReplace.length;
const newReplacement = { start, length, replacement: replacement.replacement };
if (!hasConflict(partsToReplace, newReplacement)) {
partsToReplace.push(newReplacement);
}
lastIndex = start + length;
} else {
lastIndex = -1;
}
} while (replacement.replaceAll && lastIndex >= 0);
});
partsToReplace.sort((a, b) => a.start - b.start);
const result: T[] = [];
let lastIndex = 0;
for (const { start, length, replacement } of partsToReplace) {
if (start > lastIndex) {
result.push(textWrapper(text.substr(lastIndex, start - lastIndex)));
}
result.push(replacement);
lastIndex = start + length;
}
if (lastIndex < text.length) {
result.push(textWrapper(text.substr(lastIndex)));
}
return result;
}

View File

@@ -34,6 +34,10 @@ import createReduxStore from "./createReduxStore";
import { BrowserRouter as Router } from "react-router-dom";
import { urls } from "@scm-manager/ui-components";
import { binder } from "@scm-manager/ui-extensions";
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
binder.bind("changeset.description.tokens", ChangesetShortLink);
const store = createReduxStore();

View File

@@ -32,6 +32,7 @@ import {
AvatarWrapper,
Button,
ChangesetAuthor,
ChangesetDescription,
ChangesetDiff,
ChangesetId,
changesets,
@@ -106,7 +107,7 @@ const ContributorToggleLine = styled.p`
const ChangesetSummary = styled.div`
display: flex;
justify-content: space-between;
`
`;
const SeparatedParents = styled.div`
margin-left: 1em;
@@ -180,7 +181,7 @@ class ChangesetDetails extends React.Component<Props, State> {
}}
renderAll={false}
>
{description.title}
<ChangesetDescription changeset={changeset} value={description.title} />
</ExtensionPoint>
</h4>
<article className="media">
@@ -217,7 +218,7 @@ class ChangesetDetails extends React.Component<Props, State> {
}}
renderAll={false}
>
{item}
<ChangesetDescription changeset={changeset} value={item} />
</ExtensionPoint>
<br />
</span>

View File

@@ -0,0 +1,51 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { Link } from "react-router-dom";
import { Changeset } from "@scm-manager/ui-types";
import { Replacement, changesetShortLinkRegex } from "@scm-manager/ui-components";
const ChangesetShortLink: (changeset: Changeset, value: string) => Replacement[] = (changeset, value) => {
const regex = new RegExp(changesetShortLinkRegex, "g")
const replacements: Replacement[] = [];
let m = regex.exec(value);
while (m) {
const namespace = m[1];
const name = m[2];
const revision = m[3];
const link = `/repo/${namespace}/${name}/code/changeset/${revision}`;
replacements.push({
textToReplace: m[0],
replacement: <Link to={link}>{m[0]}</Link>
});
m = regex.exec(value);
}
return replacements;
};
export default ChangesetShortLink;