diff --git a/CHANGELOG.md b/CHANGELOG.md index dd10c59fca..507c488740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/en/development/plugins/extension-points.md b/docs/en/development/plugins/extension-points.md index 43492a2931..038209f88c 100644 --- a/docs/en/development/plugins/extension-points.md +++ b/docs/en/development/plugins/extension-points.md @@ -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:** diff --git a/scm-ui/ui-components/src/MarkdownView.tsx b/scm-ui/ui-components/src/MarkdownView.tsx index e46c95370a..946fdc37c9 100644 --- a/scm-ui/ui-components/src/MarkdownView.tsx +++ b/scm-ui/ui-components/src/MarkdownView.tsx @@ -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; diff --git a/scm-ui/ui-components/src/SplitAndReplace.stories.tsx b/scm-ui/ui-components/src/SplitAndReplace.stories.tsx new file mode 100644 index 0000000000..a6d40a5195 --- /dev/null +++ b/scm-ui/ui-components/src/SplitAndReplace.stories.tsx @@ -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: , + replaceAll: true + }, + { + textToReplace: "`", + replacement: , + replaceAll: true + } + ]; + return ( + <> + + + + + + + + ); +}); diff --git a/scm-ui/ui-components/src/SplitAndReplace.tsx b/scm-ui/ui-components/src/SplitAndReplace.tsx new file mode 100644 index 0000000000..a3f72b553f --- /dev/null +++ b/scm-ui/ui-components/src/SplitAndReplace.tsx @@ -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(" ") ? <>  : ""; + const last = s.endsWith(" ") ? <>  : ""; + return ( + <> + {first} + {s} + {last} + + ); +}; + +const SplitAndReplace: FC = ({ text, replacements }) => { + const parts = textSplitAndReplace(text, replacements, textWrapper); + if (parts.length === 0) { + return <>{parts[0]}; + } + return <>{parts}; +}; + +export default SplitAndReplace; diff --git a/scm-ui/ui-components/src/__resources__/changesets.tsx b/scm-ui/ui-components/src/__resources__/changesets.tsx index e53f6a9545..2743378ad4 100644 --- a/scm-ui/ui-components/src/__resources__/changesets.tsx +++ b/scm-ui/ui-components/src/__resources__/changesets.tsx @@ -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; diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 8ac66dd2d9..1fe739ded1 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -1755,7 +1755,9 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = `

+ Added design docs +

+ 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. +

+ initialize repository + + +

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + +

+ + + +
+
+ +
+
+

+ +

+

+ +

+
+
+ + + +`; + +exports[`Storyshots Changesets Replacements 1`] = ` +
+
+
+
+
+
+
+
+

+ + HOG-42 + +   + Change mail to +   + + Arthur +

+ Change heading to "Heart Of Gold" +

+ 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. +

+ initialize repository +

+ Added design docs +

`; +exports[`Storyshots SplitAndReplace Simple replacement 1`] = ` +Array [ +

+ + + So this is it, + + +   + said Arthur, +   + + + We are going to die. + + +
, +
+ + + Yes, + + +   + said Ford, +   + + + except... no! Wait a minute! + + +
, +] +`; + exports[`Storyshots SyntaxHighlighter Go 1`] = `
{ 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"); }); diff --git a/scm-ui/ui-components/src/remarkCommitLinksParser.ts b/scm-ui/ui-components/src/remarkChangesetShortLinkParser.ts similarity index 99% rename from scm-ui/ui-components/src/remarkCommitLinksParser.ts rename to scm-ui/ui-components/src/remarkChangesetShortLinkParser.ts index fa26c793db..f9995e7131 100644 --- a/scm-ui/ui-components/src/remarkCommitLinksParser.ts +++ b/scm-ui/ui-components/src/remarkChangesetShortLinkParser.ts @@ -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) { diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx new file mode 100644 index 0000000000..3a76689eac --- /dev/null +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx @@ -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 = ({ changeset, value }) => { + const binder = useBinder(); + + const replacements: ((changeset: Changeset, value: string) => Replacement[])[] = binder.getExtensions( + "changeset.description.tokens", + { + changeset, + value + } + ); + + return r(changeset, value))} />; +}; + +export default ChangesetDescription; diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx index 0956f7f131..46a30a6fd4 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx @@ -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 { - + @@ -114,28 +115,28 @@ class ChangesetRow extends React.Component { }} renderAll={false} > - {description.title} +

- +

- +

- +
- +
- + { 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 ( + + + + ); +}; + storiesOf("Changesets", module) .addDecorator(story => {story()}) .addDecorator(storyFn => {storyFn()}) - .add("Default", () => ( - - )) - .add("With Committer", () => ( - - )) - .add("With Committer and Co-Author", () => ( - - )) - .add("With multiple Co-Authors", () => ( - - )) + .add("Default", () => ) + .add("With Committer", () => ) + .add("With Committer and Co-Author", () => ) + .add("With multiple Co-Authors", () => ) .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 = HOG-42; + const mail = Arthur; + return withReplacements( + [ + () => [{ textToReplace: "HOG-42", replacement: link }], + () => [{ textToReplace: "arthur@guide.galaxy", replacement: mail }] + ], + five + ); }); diff --git a/scm-ui/ui-components/src/repos/changesets/index.ts b/scm-ui/ui-components/src/repos/changesets/index.ts index b4c83dd4c3..3fb6b39c72 100644 --- a/scm-ui/ui-components/src/repos/changesets/index.ts +++ b/scm-ui/ui-components/src/repos/changesets/index.ts @@ -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"; diff --git a/scm-ui/ui-components/src/textSplitAndReplace.test.ts b/scm-ui/ui-components/src/textSplitAndReplace.test.ts new file mode 100644 index 0000000000..778236ee6d --- /dev/null +++ b/scm-ui/ui-components/src/textSplitAndReplace.test.ts @@ -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("Don't Panic.", [], testWrapper); + expect(result).toHaveLength(1); + expect(result[0]).toStrictEqual({ text: "Don't Panic." }); + }); + + it("should replace single string", () => { + const result = textSplitAndReplace( + "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( + "'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( + "'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( + "'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( + "'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( + "'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" }); + }); +}); diff --git a/scm-ui/ui-components/src/textSplitAndReplace.ts b/scm-ui/ui-components/src/textSplitAndReplace.ts new file mode 100644 index 0000000000..c94f85f34c --- /dev/null +++ b/scm-ui/ui-components/src/textSplitAndReplace.ts @@ -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 = { + textToReplace: string; + replacement: T; + replaceAll?: boolean; +}; + +type PartToReplace = { + start: number; + length: number; + replacement: T; +}; + +function hasConflict(alreadyFoundReplacements: PartToReplace[], newReplacement: PartToReplace) { + 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( + text: string, + replacements: Replacement[], + textWrapper: (s: string) => T +): T[] { + const partsToReplace: PartToReplace[] = []; + + 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; +} diff --git a/scm-ui/ui-webapp/src/index.tsx b/scm-ui/ui-webapp/src/index.tsx index 25a2ea0ea3..519b0c7951 100644 --- a/scm-ui/ui-webapp/src/index.tsx +++ b/scm-ui/ui-webapp/src/index.tsx @@ -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(); diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx index 6ba44c0c1d..657c47249d 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -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 { }} renderAll={false} > - {description.title} +
@@ -217,7 +218,7 @@ class ChangesetDetails extends React.Component { }} renderAll={false} > - {item} +
diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetShortLink.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetShortLink.tsx new file mode 100644 index 0000000000..6177dee5a9 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetShortLink.tsx @@ -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: {m[0]} + }); + m = regex.exec(value); + } + + return replacements; +}; + +export default ChangesetShortLink;