From b85dc8f0e67c249c71049dc3b5d8a084026fecfa Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 18 Feb 2022 14:47:37 +0100 Subject: [PATCH] Split frontend code by routes (#1955) Split large frontend components into own bundles. This way we decrease loading times and load the bundles right as they are used. We replace SystemJS with our own implementation to load the lazy modules right as there are required. Co-authored-by: Sebastian Sdorra --- .../groovy/com/cloudogu/scm/RunPlugin.groovy | 2 + .../groovy/com/cloudogu/scm/RunTask.groovy | 17 +- gradle/changelog/code_splitting.yaml | 2 + package.json | 2 +- scm-ui/ui-api/src/ApiProvider.test.tsx | 8 +- scm-ui/ui-api/src/ApiProvider.tsx | 6 +- scm-ui/ui-api/src/LegacyContext.tsx | 38 +- scm-ui/ui-api/src/base.test.ts | 67 +-- scm-ui/ui-api/src/index.ts | 2 + scm-ui/ui-api/src/login.test.ts | 27 +- .../src/markdown/LazyMarkdownView.tsx | 230 ++++++++ .../src/markdown/MarkdownView.tsx | 213 +------ scm-ui/ui-components/src/repos/Diff.tsx | 4 +- scm-ui/ui-components/src/repos/DiffFile.tsx | 502 +---------------- .../ui-components/src/repos/LazyDiffFile.tsx | 520 ++++++++++++++++++ scm-ui/ui-legacy/package.json | 37 ++ .../src/LegacyReduxProvider.tsx | 24 +- .../src/ReduxLegacy.tsx} | 33 +- scm-ui/ui-legacy/src/index.ts | 32 ++ scm-ui/ui-legacy/tsconfig.json | 3 + scm-ui/ui-modules/package.json | 27 + scm-ui/ui-modules/src/index.ts | 91 +++ scm-ui/ui-modules/tsconfig.json | 3 + scm-ui/ui-scripts/package.json | 3 +- scm-ui/ui-scripts/src/webpack.config.js | 12 +- scm-ui/ui-webapp/package.json | 1 + scm-ui/ui-webapp/src/containers/Main.tsx | 114 ++-- .../ui-webapp/src/containers/PluginLoader.tsx | 20 +- scm-ui/ui-webapp/src/containers/loadBundle.ts | 125 +---- scm-ui/ui-webapp/src/index.tsx | 19 +- yarn.lock | 68 ++- 31 files changed, 1291 insertions(+), 961 deletions(-) create mode 100644 gradle/changelog/code_splitting.yaml create mode 100644 scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx create mode 100644 scm-ui/ui-components/src/repos/LazyDiffFile.tsx create mode 100644 scm-ui/ui-legacy/package.json rename scm-ui/{ui-webapp => ui-legacy}/src/LegacyReduxProvider.tsx (82%) rename scm-ui/{ui-webapp/src/ReduxAwareApiProvider.tsx => ui-legacy/src/ReduxLegacy.tsx} (67%) create mode 100644 scm-ui/ui-legacy/src/index.ts create mode 100644 scm-ui/ui-legacy/tsconfig.json create mode 100644 scm-ui/ui-modules/package.json create mode 100644 scm-ui/ui-modules/src/index.ts create mode 100644 scm-ui/ui-modules/tsconfig.json diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/RunPlugin.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/RunPlugin.groovy index eab0960dfd..d4dbdd51ae 100644 --- a/build-plugins/src/main/groovy/com/cloudogu/scm/RunPlugin.groovy +++ b/build-plugins/src/main/groovy/com/cloudogu/scm/RunPlugin.groovy @@ -43,9 +43,11 @@ class RunPlugin implements Plugin { project.tasks.register('write-server-config', WriteServerConfigTask) { it.extension = extension + dependsOn 'dev-war' } project.tasks.register('prepare-home', PrepareHomeTask) { it.extension = extension + dependsOn 'dev-war' } project.tasks.register("run", RunTask) { it.extension = extension diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy index 64f3b35d3b..e700a78a0e 100644 --- a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy +++ b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy @@ -44,6 +44,10 @@ class RunTask extends DefaultTask { @Nested ScmServerExtension extension + @Input + @Option(option = 'analyze-bundles', description = 'Include Webpack Bundle Analyzer Plugin') + boolean analyzeBundles = false + @Input @Option(option = 'debug-jvm', description = 'Start ScmServer suspended and listening on debug port (default: 5005)') boolean debugJvm = false @@ -73,7 +77,7 @@ class RunTask extends DefaultTask { private void waitForPortToBeOpen() { int retries = 180 - for (int i=0; i createBackend() { - Map scmProperties = System.getProperties().findAll { e -> { - return e.key.startsWith("scm") || e.key.startsWith("sonia") - }} + Map scmProperties = System.getProperties().findAll { e -> + { + return e.key.startsWith("scm") || e.key.startsWith("sonia") + } + } def runProperties = new HashMap(scmProperties) runProperties.put("user.home", extension.getHome()) @@ -137,7 +143,8 @@ class RunTask extends DefaultTask { script = new File(project.rootProject.projectDir, 'scm-ui/ui-scripts/bin/ui-scripts.js') args = ['serve'] environment = [ - 'NODE_ENV': 'development' + 'NODE_ENV': 'development', + 'ANALYZE_BUNDLES': analyzeBundles ] } return { diff --git a/gradle/changelog/code_splitting.yaml b/gradle/changelog/code_splitting.yaml new file mode 100644 index 0000000000..4b412f3a8a --- /dev/null +++ b/gradle/changelog/code_splitting.yaml @@ -0,0 +1,2 @@ +- type: changed + description: Split frontend code by routes ([#1955](https://github.com/scm-manager/scm-manager/pull/1955)) diff --git a/package.json b/package.json index 5d8b6d1a37..fd6739079e 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ }, "dependencies": {}, "devDependencies": { + "@scm-manager/remark-preset-lint": "^1.0.0", "babel-plugin-reflow": "^0.2.7", "husky": "^4.2.5", "lerna": "^3.17.0", "lint-staged": "^10.2.11", - "@scm-manager/remark-preset-lint": "^1.0.0", "remark-cli": "^9.0.0" }, "resolutions": { diff --git a/scm-ui/ui-api/src/ApiProvider.test.tsx b/scm-ui/ui-api/src/ApiProvider.test.tsx index f9e7e5e462..f7dc216a67 100644 --- a/scm-ui/ui-api/src/ApiProvider.test.tsx +++ b/scm-ui/ui-api/src/ApiProvider.test.tsx @@ -23,20 +23,20 @@ */ import { LegacyContext, useLegacyContext } from "./LegacyContext"; +import * as React from "react"; import { FC } from "react"; import { renderHook } from "@testing-library/react-hooks"; -import * as React from "react"; import ApiProvider from "./ApiProvider"; import { useQueryClient } from "react-query"; describe("ApiProvider tests", () => { - const createWrapper = (context?: LegacyContext): FC => { + const createWrapper = (context: LegacyContext): FC => { return ({ children }) => {children}; }; it("should register QueryClient", () => { const { result } = renderHook(() => useQueryClient(), { - wrapper: createWrapper(), + wrapper: createWrapper({ initialize: () => null }) }); expect(result.current).toBeDefined(); }); @@ -48,7 +48,7 @@ describe("ApiProvider tests", () => { }; const { result } = renderHook(() => useLegacyContext(), { - wrapper: createWrapper({ onIndexFetched }), + wrapper: createWrapper({ onIndexFetched, initialize: () => null }) }); if (result.current?.onIndexFetched) { diff --git a/scm-ui/ui-api/src/ApiProvider.tsx b/scm-ui/ui-api/src/ApiProvider.tsx index 2622164e07..8319f55cb5 100644 --- a/scm-ui/ui-api/src/ApiProvider.tsx +++ b/scm-ui/ui-api/src/ApiProvider.tsx @@ -31,9 +31,9 @@ import { reset } from "./reset"; const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: false, - }, - }, + retry: false + } + } }); type Props = LegacyContext & { diff --git a/scm-ui/ui-api/src/LegacyContext.tsx b/scm-ui/ui-api/src/LegacyContext.tsx index aacd07e0b2..e3e6760407 100644 --- a/scm-ui/ui-api/src/LegacyContext.tsx +++ b/scm-ui/ui-api/src/LegacyContext.tsx @@ -24,12 +24,17 @@ import { IndexResources, Me } from "@scm-manager/ui-types"; import React, { createContext, FC, useContext } from "react"; +import { QueryClient, useQueryClient } from "react-query"; -export type LegacyContext = { +export type BaseContext = { onIndexFetched?: (index: IndexResources) => void; onMeFetched?: (me: Me) => void; }; +export type LegacyContext = BaseContext & { + initialize: () => void; +}; + const Context = createContext(undefined); export const useLegacyContext = () => { @@ -40,6 +45,31 @@ export const useLegacyContext = () => { return context; }; -export const LegacyContextProvider: FC = ({ onIndexFetched, onMeFetched, children }) => ( - {children} -); +const createInitialContext = (queryClient: QueryClient, base: BaseContext): LegacyContext => { + const ctx = { + ...base, + initialize: () => { + if (ctx.onIndexFetched) { + const index: IndexResources | undefined = queryClient.getQueryData("index"); + if (index) { + ctx.onIndexFetched(index); + } + } + if (ctx.onMeFetched) { + const me: Me | undefined = queryClient.getQueryData("me"); + if (me) { + ctx.onMeFetched(me); + } + } + } + }; + + return ctx; +}; + +export const LegacyContextProvider: FC = ({ onIndexFetched, onMeFetched, children }) => { + const queryClient = useQueryClient(); + const ctx = createInitialContext(queryClient, { onIndexFetched, onMeFetched }); + + return {children}; +}; diff --git a/scm-ui/ui-api/src/base.test.ts b/scm-ui/ui-api/src/base.test.ts index 2103e5cb6c..1bf579f531 100644 --- a/scm-ui/ui-api/src/base.test.ts +++ b/scm-ui/ui-api/src/base.test.ts @@ -34,7 +34,7 @@ describe("Test base api hooks", () => { describe("useIndex tests", () => { fetchMock.get("/api/v2/", { version: "x.y.z", - _links: {}, + _links: {} }); it("should return index", async () => { @@ -48,9 +48,10 @@ describe("Test base api hooks", () => { it("should call onIndexFetched of LegacyContext", async () => { let index: IndexResources; const context: LegacyContext = { - onIndexFetched: (fetchedIndex) => { + onIndexFetched: fetchedIndex => { index = fetchedIndex; }, + initialize: () => null }; const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper(context) }); await waitFor(() => { @@ -70,10 +71,10 @@ describe("Test base api hooks", () => { const queryClient = new QueryClient(); queryClient.setQueryData("index", { version: "x.y.z", - _links: {}, + _links: {} }); const { result } = renderHook(() => useIndexLink("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current).toBeUndefined(); }); @@ -86,17 +87,17 @@ describe("Test base api hooks", () => { spaceships: [ { name: "heartOfGold", - href: "/spaceships/heartOfGold", + href: "/spaceships/heartOfGold" }, { name: "razorCrest", - href: "/spaceships/razorCrest", - }, - ], - }, + href: "/spaceships/razorCrest" + } + ] + } }); const { result } = renderHook(() => useIndexLink("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current).toBeUndefined(); }); @@ -107,12 +108,12 @@ describe("Test base api hooks", () => { version: "x.y.z", _links: { spaceships: { - href: "/api/spaceships", - }, - }, + href: "/api/spaceships" + } + } }); const { result } = renderHook(() => useIndexLink("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current).toBe("/api/spaceships"); }); @@ -130,12 +131,12 @@ describe("Test base api hooks", () => { version: "x.y.z", _links: { spaceships: { - href: "/api/spaceships", - }, - }, + href: "/api/spaceships" + } + } }); const { result } = renderHook(() => useIndexLinks(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect((result.current!.spaceships as Link).href).toBe("/api/spaceships"); }); @@ -150,10 +151,10 @@ describe("Test base api hooks", () => { it("should return version", () => { const queryClient = new QueryClient(); queryClient.setQueryData("index", { - version: "x.y.z", + version: "x.y.z" }); const { result } = renderHook(() => useVersion(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current).toBe("x.y.z"); }); @@ -164,10 +165,10 @@ describe("Test base api hooks", () => { const queryClient = new QueryClient(); queryClient.setQueryData("index", { version: "x.y.z", - _links: {}, + _links: {} }); const { result } = renderHook(() => useRequiredIndexLink("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.error).toBeDefined(); }); @@ -178,12 +179,12 @@ describe("Test base api hooks", () => { version: "x.y.z", _links: { spaceships: { - href: "/api/spaceships", - }, - }, + href: "/api/spaceships" + } + } }); const { result } = renderHook(() => useRequiredIndexLink("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current).toBe("/api/spaceships"); }); @@ -196,19 +197,19 @@ describe("Test base api hooks", () => { version: "x.y.z", _links: { spaceships: { - href: "/spaceships", - }, - }, + href: "/spaceships" + } + } }); const spaceship = { - name: "heartOfGold", + name: "heartOfGold" }; fetchMock.get("/api/v2/spaceships", spaceship); const { result, waitFor } = renderHook(() => useIndexJsonResource("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { @@ -223,11 +224,11 @@ describe("Test base api hooks", () => { const queryClient = new QueryClient(); queryClient.setQueryData("index", { version: "x.y.z", - _links: {}, + _links: {} }); const { result } = renderHook(() => useIndexJsonResource<{}>("spaceships"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current.isLoading).toBe(false); diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 16413856f5..5eac18ab82 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -66,3 +66,5 @@ export * from "./compare"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; + +export * from "./LegacyContext"; diff --git a/scm-ui/ui-api/src/login.test.ts b/scm-ui/ui-api/src/login.test.ts index cefc9d0a32..6b794c8037 100644 --- a/scm-ui/ui-api/src/login.test.ts +++ b/scm-ui/ui-api/src/login.test.ts @@ -37,7 +37,7 @@ describe("Test login hooks", () => { name: "tricia", displayName: "Tricia", groups: [], - _links: {}, + _links: {} }; describe("useMe tests", () => { @@ -45,7 +45,7 @@ describe("Test login hooks", () => { name: "tricia", displayName: "Tricia", groups: [], - _links: {}, + _links: {} }); it("should return me", async () => { @@ -65,9 +65,10 @@ describe("Test login hooks", () => { let me: Me; const context: LegacyContext = { - onMeFetched: (fetchedMe) => { + onMeFetched: fetchedMe => { me = fetchedMe; }, + initialize: () => null }; const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(context, queryClient) }); @@ -130,7 +131,7 @@ describe("Test login hooks", () => { name: "_anonymous", displayName: "Anonymous", groups: [], - _links: {}, + _links: {} }); const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) }); @@ -158,8 +159,8 @@ describe("Test login hooks", () => { cookie: true, grant_type: "password", username: "tricia", - password: "hitchhikersSecret!", - }, + password: "hitchhikersSecret!" + } }); // required because we invalidate the whole cache and react-query refetches the index @@ -167,13 +168,13 @@ describe("Test login hooks", () => { version: "x.y.z", _links: { login: { - href: "/second/login", - }, - }, + href: "/second/login" + } + } }); const { result, waitForNextUpdate } = renderHook(() => useLogin(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); const { login } = result.current; expect(login).toBeDefined(); @@ -194,7 +195,7 @@ describe("Test login hooks", () => { queryClient.setQueryData("me", tricia); const { result } = renderHook(() => useLogin(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.current.login).toBeUndefined(); @@ -209,7 +210,7 @@ describe("Test login hooks", () => { fetchMock.deleteOnce("/api/v2/logout", {}); const { result, waitForNextUpdate } = renderHook(() => useLogout(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); const { logout } = result.current; expect(logout).toBeDefined(); @@ -229,7 +230,7 @@ describe("Test login hooks", () => { setEmptyIndex(queryClient); const { result } = renderHook(() => useLogout(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); const { logout } = result.current; diff --git a/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx b/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx new file mode 100644 index 0000000000..af848b5a2b --- /dev/null +++ b/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx @@ -0,0 +1,230 @@ +/* + * 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 { RouteComponentProps, withRouter } from "react-router-dom"; +import unified from "unified"; +import parseMarkdown from "remark-parse"; +import sanitize from "rehype-sanitize"; +import remark2rehype from "remark-rehype"; +import rehype2react from "rehype-react"; +import gfm from "remark-gfm"; +import { BinderContext } from "@scm-manager/ui-extensions"; +import ErrorBoundary from "../ErrorBoundary"; +import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer"; +import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer"; +import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; +import Notification from "../Notification"; +import { createTransformer as createChangesetShortlinkParser } from "./remarkChangesetShortLinkParser"; +import { createTransformer as createValuelessTextAdapter } from "./remarkValuelessTextAdapter"; +import MarkdownCodeRenderer from "./MarkdownCodeRenderer"; +import { AstPlugin } from "./PluginApi"; +import createMdastPlugin from "./createMdastPlugin"; +// @ts-ignore +import gh from "hast-util-sanitize/lib/github"; +import raw from "rehype-raw"; +import slug from "rehype-slug"; +import merge from "deepmerge"; +import { createComponentList } from "./createComponentList"; +import { ProtocolLinkRendererExtension, ProtocolLinkRendererExtensionMap } from "./markdownExtensions"; + +export type MarkdownProps = { + content: string; + renderContext?: object; + renderers?: any; + skipHtml?: boolean; + enableAnchorHeadings?: boolean; + // basePath for markdown links + basePath?: string; + permalink?: string; + mdastPlugins?: AstPlugin[]; +}; + +type Props = RouteComponentProps & WithTranslation & MarkdownProps; + +type State = { + contentRef: HTMLDivElement | null | undefined; +}; + +const xmlMarkupSample = `\`\`\`xml + + + + + +\`\`\``; + +const MarkdownErrorNotification: FC = () => { + const [t] = useTranslation("commons"); + return ( + +
+

{t("markdownErrorNotification.title")}

+

{t("markdownErrorNotification.description")}

+
+          {xmlMarkupSample}
+        
+

+ {t("markdownErrorNotification.spec")}:{" "} + + GitHub Flavored Markdown Spec + +

+
+
+ ); +}; + +class LazyMarkdownView extends React.Component { + static contextType = BinderContext; + + static defaultProps: Partial = { + enableAnchorHeadings: false, + skipHtml: false + }; + + constructor(props: Props) { + super(props); + this.state = { + contentRef: null + }; + } + + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean { + // We have check if the contentRef changed and update afterwards so the page can scroll to the anchor links. + // Otherwise it can happen that componentDidUpdate is never executed depending on how fast the markdown got rendered + // We also have to check if props have changed, because we also want to rerender if one of our props has changed + return this.state.contentRef !== nextState.contentRef || this.props !== nextProps; + } + + componentDidUpdate() { + const { contentRef } = this.state; + // we have to use componentDidUpdate, because we have to wait until all + // children are rendered and componentDidMount is called before the + // markdown content was rendered. + const hash = this.props.location.hash; + if (contentRef && hash) { + // we query only child elements, to avoid strange scrolling with multiple + // markdown elements on one page. + const elementId = decodeURIComponent(hash.substring(1) /* remove # */); + const element = contentRef.querySelector(`[id="${elementId}"]`); + if (element && element.scrollIntoView) { + element.scrollIntoView(); + } + } + } + + render() { + const { + content, + renderers, + renderContext, + enableAnchorHeadings, + skipHtml, + basePath, + permalink, + t, + mdastPlugins = [] + } = this.props; + + const rendererFactory = this.context.getExtension("markdown-renderer-factory"); + let remarkRendererList = renderers; + + if (rendererFactory) { + remarkRendererList = rendererFactory(renderContext); + } + + if (!remarkRendererList) { + remarkRendererList = {}; + } + + if (enableAnchorHeadings && permalink && !remarkRendererList.heading) { + remarkRendererList.heading = createMarkdownHeadingRenderer(permalink); + } + + let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {}; + if (!remarkRendererList.link) { + const extensionPoints = this.context.getExtensions( + "markdown-renderer.link.protocol" + ) as ProtocolLinkRendererExtension[]; + protocolLinkRendererExtensions = extensionPoints.reduce( + (prev, { protocol, renderer }) => { + prev[protocol] = renderer; + return prev; + }, + {} + ); + remarkRendererList.link = createMarkdownLinkRenderer(basePath, protocolLinkRendererExtensions); + } + + if (!remarkRendererList.code) { + remarkRendererList.code = MarkdownCodeRenderer; + } + + const remarkPlugins = [...mdastPlugins, createChangesetShortlinkParser(t), createValuelessTextAdapter()].map( + createMdastPlugin + ); + + let processor = unified() + .use(parseMarkdown) + .use(gfm) + .use(remarkPlugins) + .use(remark2rehype, { allowDangerousHtml: true }); + + if (!skipHtml) { + processor = processor.use(raw); + } + + processor = processor + .use(slug) + .use( + sanitize, + merge(gh, { + attributes: { + code: ["className"] // Allow className for code elements, this is necessary to extract the code language + }, + clobberPrefix: "", // Do not prefix user-provided ids and class names, + protocols: { + href: Object.keys(protocolLinkRendererExtensions) + } + }) + ) + .use(rehype2react, { + createElement: React.createElement, + passNode: true, + components: createComponentList(remarkRendererList, { permalink }) + }); + + const renderedMarkdown: any = processor.processSync(content).result; + + return ( + +
this.setState({ contentRef: el })} className="content is-word-break"> + {renderedMarkdown} +
+
+ ); + } +} + +export default withRouter(withTranslation("repos")(LazyMarkdownView)); diff --git a/scm-ui/ui-components/src/markdown/MarkdownView.tsx b/scm-ui/ui-components/src/markdown/MarkdownView.tsx index 464acef034..a526e6f5ab 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownView.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownView.tsx @@ -21,209 +21,16 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC } from "react"; -import { RouteComponentProps, withRouter } from "react-router-dom"; -import unified from "unified"; -import parseMarkdown from "remark-parse"; -import sanitize from "rehype-sanitize"; -import remark2rehype from "remark-rehype"; -import rehype2react from "rehype-react"; -import gfm from "remark-gfm"; -import { BinderContext } from "@scm-manager/ui-extensions"; -import ErrorBoundary from "../ErrorBoundary"; -import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer"; -import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer"; -import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; -import Notification from "../Notification"; -import { createTransformer as createChangesetShortlinkParser } from "./remarkChangesetShortLinkParser"; -import { createTransformer as createValuelessTextAdapter } from "./remarkValuelessTextAdapter"; -import MarkdownCodeRenderer from "./MarkdownCodeRenderer"; -import { AstPlugin } from "./PluginApi"; -import createMdastPlugin from "./createMdastPlugin"; -// @ts-ignore -import gh from "hast-util-sanitize/lib/github"; -import raw from "rehype-raw"; -import slug from "rehype-slug"; -import merge from "deepmerge"; -import { createComponentList } from "./createComponentList"; -import { ProtocolLinkRendererExtension, ProtocolLinkRendererExtensionMap } from "./markdownExtensions"; +import React, { FC, Suspense } from "react"; +import { MarkdownProps } from "./LazyMarkdownView"; +import Loading from "../Loading"; -type Props = RouteComponentProps & - WithTranslation & { - content: string; - renderContext?: object; - renderers?: any; - skipHtml?: boolean; - enableAnchorHeadings?: boolean; - // basePath for markdown links - basePath?: string; - permalink?: string; - mdastPlugins?: AstPlugin[]; - }; +const LazyMarkdownView = React.lazy(() => import("./LazyMarkdownView")); -type State = { - contentRef: HTMLDivElement | null | undefined; -}; +const MarkdownView: FC = props => ( + }> + + +); -const xmlMarkupSample = `\`\`\`xml - - - - - -\`\`\``; - -const MarkdownErrorNotification: FC = () => { - const [t] = useTranslation("commons"); - return ( - -
-

{t("markdownErrorNotification.title")}

-

{t("markdownErrorNotification.description")}

-
-          {xmlMarkupSample}
-        
-

- {t("markdownErrorNotification.spec")}:{" "} - - GitHub Flavored Markdown Spec - -

-
-
- ); -}; - -class MarkdownView extends React.Component { - static contextType = BinderContext; - - static defaultProps: Partial = { - enableAnchorHeadings: false, - skipHtml: false, - }; - - constructor(props: Props) { - super(props); - this.state = { - contentRef: null, - }; - } - - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean { - // We have check if the contentRef changed and update afterwards so the page can scroll to the anchor links. - // Otherwise it can happen that componentDidUpdate is never executed depending on how fast the markdown got rendered - // We also have to check if props have changed, because we also want to rerender if one of our props has changed - return this.state.contentRef !== nextState.contentRef || this.props !== nextProps; - } - - componentDidUpdate() { - const { contentRef } = this.state; - // we have to use componentDidUpdate, because we have to wait until all - // children are rendered and componentDidMount is called before the - // markdown content was rendered. - const hash = this.props.location.hash; - if (contentRef && hash) { - // we query only child elements, to avoid strange scrolling with multiple - // markdown elements on one page. - const elementId = decodeURIComponent(hash.substring(1) /* remove # */); - const element = contentRef.querySelector(`[id="${elementId}"]`); - if (element && element.scrollIntoView) { - element.scrollIntoView(); - } - } - } - - render() { - const { - content, - renderers, - renderContext, - enableAnchorHeadings, - skipHtml, - basePath, - permalink, - t, - mdastPlugins = [], - } = this.props; - - const rendererFactory = this.context.getExtension("markdown-renderer-factory"); - let remarkRendererList = renderers; - - if (rendererFactory) { - remarkRendererList = rendererFactory(renderContext); - } - - if (!remarkRendererList) { - remarkRendererList = {}; - } - - if (enableAnchorHeadings && permalink && !remarkRendererList.heading) { - remarkRendererList.heading = createMarkdownHeadingRenderer(permalink); - } - - let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {}; - if (!remarkRendererList.link) { - const extensionPoints = this.context.getExtensions( - "markdown-renderer.link.protocol" - ) as ProtocolLinkRendererExtension[]; - protocolLinkRendererExtensions = extensionPoints.reduce( - (prev, { protocol, renderer }) => { - prev[protocol] = renderer; - return prev; - }, - {} - ); - remarkRendererList.link = createMarkdownLinkRenderer(basePath, protocolLinkRendererExtensions); - } - - if (!remarkRendererList.code) { - remarkRendererList.code = MarkdownCodeRenderer; - } - - const remarkPlugins = [...mdastPlugins, createChangesetShortlinkParser(t), createValuelessTextAdapter()].map( - createMdastPlugin - ); - - let processor = unified() - .use(parseMarkdown) - .use(gfm) - .use(remarkPlugins) - .use(remark2rehype, { allowDangerousHtml: true }); - - if (!skipHtml) { - processor = processor.use(raw); - } - - processor = processor - .use(slug) - .use( - sanitize, - merge(gh, { - attributes: { - code: ["className"], // Allow className for code elements, this is necessary to extract the code language - }, - clobberPrefix: "", // Do not prefix user-provided ids and class names, - protocols: { - href: Object.keys(protocolLinkRendererExtensions), - }, - }) - ) - .use(rehype2react, { - createElement: React.createElement, - passNode: true, - components: createComponentList(remarkRendererList, { permalink }), - }); - - const renderedMarkdown: any = processor.processSync(content).result; - - return ( - -
this.setState({ contentRef: el })} className="content is-word-break"> - {renderedMarkdown} -
-
- ); - } -} - -export default withRouter(withTranslation("repos")(MarkdownView)); +export default MarkdownView; diff --git a/scm-ui/ui-components/src/repos/Diff.tsx b/scm-ui/ui-components/src/repos/Diff.tsx index eb9bbd771e..65aab1997f 100644 --- a/scm-ui/ui-components/src/repos/Diff.tsx +++ b/scm-ui/ui-components/src/repos/Diff.tsx @@ -64,14 +64,14 @@ const Diff: FC = ({ diff, ...fileProps }) => { {diff.length === 0 ? ( {t("diff.noDiffFound")} ) : ( - diff.map((file) => ) + diff.map(file => ) )} ); }; Diff.defaultProps = { - sideBySide: false, + sideBySide: false }; export default Diff; diff --git a/scm-ui/ui-components/src/repos/DiffFile.tsx b/scm-ui/ui-components/src/repos/DiffFile.tsx index 3f721843f0..05c7aa1bfe 100644 --- a/scm-ui/ui-components/src/repos/DiffFile.tsx +++ b/scm-ui/ui-components/src/repos/DiffFile.tsx @@ -21,499 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { withTranslation, WithTranslation } from "react-i18next"; -import classNames from "classnames"; -import styled from "styled-components"; -// @ts-ignore -import { Decoration, getChangeKey, Hunk } from "react-diff-view"; -import { ButtonGroup } from "../buttons"; -import Tag from "../Tag"; -import Icon from "../Icon"; -import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types"; -import { ChangeEvent, DiffObjectProps } from "./DiffTypes"; -import TokenizedDiffView from "./TokenizedDiffView"; -import DiffButton from "./DiffButton"; -import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components"; -import DiffExpander, { ExpandableHunk } from "./DiffExpander"; -import HunkExpandLink from "./HunkExpandLink"; -import { Modal } from "../modals"; -import ErrorNotification from "../ErrorNotification"; -import HunkExpandDivider from "./HunkExpandDivider"; -import { escapeWhitespace } from "./diffs"; -const EMPTY_ANNOTATION_FACTORY = {}; +import React, { FC, Suspense } from "react"; +import { DiffFileProps } from "./LazyDiffFile"; +import Loading from "../Loading"; -type Props = DiffObjectProps & - WithTranslation & { - file: FileDiff; - }; +const LazyDiffFile = React.lazy(() => import("./LazyDiffFile")); -type Collapsible = { - collapsed?: boolean; -}; +const DiffFile: FC = props => ( + }> + + +); -type State = Collapsible & { - file: FileDiff; - sideBySide?: boolean; - diffExpander: DiffExpander; - expansionError?: any; -}; - -const DiffFilePanel = styled.div` - /* remove bottom border for collapsed panels */ - ${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")}; -`; - -const FullWidthTitleHeader = styled.div` - max-width: 100%; -`; - -const MarginlessModalContent = styled.div` - margin: -1.25rem; - - & .panel-block { - flex-direction: column; - align-items: stretch; - } -`; - -class DiffFile extends React.Component { - static defaultProps: Partial = { - defaultCollapse: false, - markConflicts: true - }; - - constructor(props: Props) { - super(props); - this.state = { - collapsed: this.defaultCollapse(), - sideBySide: props.sideBySide, - diffExpander: new DiffExpander(props.file), - file: props.file - }; - } - - componentDidUpdate(prevProps: Readonly) { - if (!this.props.isCollapsed && this.props.defaultCollapse !== prevProps.defaultCollapse) { - this.setState({ - collapsed: this.defaultCollapse() - }); - } - } - - defaultCollapse: () => boolean = () => { - const { defaultCollapse, file } = this.props; - if (typeof defaultCollapse === "boolean") { - return defaultCollapse; - } else if (typeof defaultCollapse === "function") { - return defaultCollapse(file.oldPath, file.newPath); - } else { - return false; - } - }; - - toggleCollapse = () => { - const { onCollapseStateChange } = this.props; - const { file } = this.state; - if (this.hasContent(file)) { - if (onCollapseStateChange) { - onCollapseStateChange(file); - } else { - this.setState(state => ({ - collapsed: !state.collapsed - })); - } - } - }; - - toggleSideBySide = (callback: () => void) => { - this.setState( - state => ({ - sideBySide: !state.sideBySide - }), - () => callback() - ); - }; - - setCollapse = (collapsed: boolean) => { - const { onCollapseStateChange } = this.props; - if (onCollapseStateChange) { - onCollapseStateChange(this.state.file, collapsed); - } else { - this.setState({ - collapsed - }); - } - }; - - createHunkHeader = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandHeadRange > 0) { - if (expandableHunk.maxExpandHeadRange <= 10) { - return ( - - - - ); - } else { - return ( - - {" "} - - - ); - } - } - // hunk header must be defined - return ; - }; - - createHunkFooter = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandBottomRange > 0) { - if (expandableHunk.maxExpandBottomRange <= 10) { - return ( - - - - ); - } else { - return ( - - {" "} - - - ); - } - } - // hunk footer must be defined - return ; - }; - - createLastHunkFooter = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandBottomRange !== 0) { - return ( - - {" "} - - - ); - } - // hunk header must be defined - return ; - }; - - expandHead = (expandableHunk: ExpandableHunk, count: number) => { - return () => { - return expandableHunk - .expandHead(count) - .then(this.diffExpanded) - .catch(this.diffExpansionFailed); - }; - }; - - expandBottom = (expandableHunk: ExpandableHunk, count: number) => { - return () => { - return expandableHunk - .expandBottom(count) - .then(this.diffExpanded) - .catch(this.diffExpansionFailed); - }; - }; - - diffExpanded = (newFile: FileDiff) => { - this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) }); - }; - - diffExpansionFailed = (err: any) => { - this.setState({ expansionError: err }); - }; - - collectHunkAnnotations = (hunk: HunkType) => { - const { annotationFactory } = this.props; - const { file } = this.state; - if (annotationFactory) { - return annotationFactory({ - hunk, - file - }); - } else { - return EMPTY_ANNOTATION_FACTORY; - } - }; - - handleClickEvent = (change: Change, hunk: HunkType) => { - const { onClick } = this.props; - const { file } = this.state; - const context = { - changeId: getChangeKey(change), - change, - hunk, - file - }; - if (onClick) { - onClick(context); - } - }; - - createGutterEvents = (hunk: HunkType) => { - const { onClick } = this.props; - if (onClick) { - return { - onClick: (event: ChangeEvent) => { - this.handleClickEvent(event.change, hunk); - } - }; - } - }; - - renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => { - const hunk = expandableHunk.hunk; - if (this.props.markConflicts && hunk.changes) { - this.markConflicts(hunk); - } - const items = []; - if (file._links?.lines) { - items.push(this.createHunkHeader(expandableHunk)); - } else if (i > 0) { - items.push( - -
-
- ); - } - - items.push( - - ); - if (file._links?.lines) { - if (i === file.hunks!.length - 1) { - items.push(this.createLastHunkFooter(expandableHunk)); - } else { - items.push(this.createHunkFooter(expandableHunk)); - } - } - return items; - }; - - markConflicts = (hunk: HunkType) => { - let inConflict = false; - for (let i = 0; i < hunk.changes.length; ++i) { - if (hunk.changes[i].content === "<<<<<<< HEAD") { - inConflict = true; - } - if (inConflict) { - hunk.changes[i].type = "conflict"; - } - if (hunk.changes[i].content.startsWith(">>>>>>>")) { - inConflict = false; - } - } - }; - - getAnchorId(file: FileDiff) { - let path: string; - if (file.type === "delete") { - path = file.oldPath; - } else { - path = file.newPath; - } - return escapeWhitespace(path); - } - - renderFileTitle = (file: FileDiff) => { - const { t } = this.props; - if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { - return ( - <> - {file.oldPath} {file.newPath} - - ); - } else if (file.type === "delete") { - return file.oldPath; - } - return file.newPath; - }; - - hoverFileTitle = (file: FileDiff): string => { - if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { - return `${file.oldPath} > ${file.newPath}`; - } else if (file.type === "delete") { - return file.oldPath; - } - return file.newPath; - }; - - renderChangeTag = (file: FileDiff) => { - const { t } = this.props; - if (!file.type) { - return; - } - const key = "diff.changes." + file.type; - let value = t(key); - if (key === value) { - value = file.type; - } - - const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info"; - return ( - - ); - }; - - isCollapsed = () => { - const { file, isCollapsed } = this.props; - if (isCollapsed) { - return isCollapsed(file); - } - return this.state.collapsed; - }; - - hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0; - - render() { - const { fileControlFactory, fileAnnotationFactory, t } = this.props; - const { file, sideBySide, diffExpander, expansionError } = this.state; - const viewType = sideBySide ? "split" : "unified"; - const collapsed = this.isCollapsed(); - - const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null; - const innerContent = ( -
- {fileAnnotations} - - {(hunks: HunkType[]) => - hunks?.map((hunk, n) => { - return this.renderHunk(file, diffExpander.getHunk(n), n); - }) - } - -
- ); - let icon = ; - let body = null; - if (!collapsed) { - icon = ; - body = innerContent; - } - const collapseIcon = this.hasContent(file) ? icon : null; - const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null; - const modalTitle = file.type === "delete" ? file.oldPath : file.newPath; - const openInFullscreen = file?.hunks?.length ? ( - {innerContent}} - /> - ) : null; - const sideBySideToggle = file?.hunks?.length && ( - - {({ setCollapsed }) => ( - - this.toggleSideBySide(() => { - if (this.state.sideBySide) { - setCollapsed(true); - } - }) - } - /> - )} - - ); - const headerButtons = ( -
- - {sideBySideToggle} - {openInFullscreen} - {fileControls} - -
- ); - - let errorModal; - if (expansionError) { - errorModal = ( - this.setState({ expansionError: undefined })} - body={} - active={true} - /> - ); - } - - return ( - - {errorModal} -
-
- - {collapseIcon} -

- {this.renderFileTitle(file)} -

- {this.renderChangeTag(file)} -
- {headerButtons} -
-
- {body} -
- ); - } -} - -export default withTranslation("repos")(DiffFile); +export default DiffFile; diff --git a/scm-ui/ui-components/src/repos/LazyDiffFile.tsx b/scm-ui/ui-components/src/repos/LazyDiffFile.tsx new file mode 100644 index 0000000000..15087c88d6 --- /dev/null +++ b/scm-ui/ui-components/src/repos/LazyDiffFile.tsx @@ -0,0 +1,520 @@ +/* + * 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 { withTranslation, WithTranslation } from "react-i18next"; +import classNames from "classnames"; +import styled from "styled-components"; +// @ts-ignore +import { Decoration, getChangeKey, Hunk } from "react-diff-view"; +import { ButtonGroup } from "../buttons"; +import Tag from "../Tag"; +import Icon from "../Icon"; +import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types"; +import { ChangeEvent, DiffObjectProps } from "./DiffTypes"; +import TokenizedDiffView from "./TokenizedDiffView"; +import DiffButton from "./DiffButton"; +import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components"; +import DiffExpander, { ExpandableHunk } from "./DiffExpander"; +import HunkExpandLink from "./HunkExpandLink"; +import { Modal } from "../modals"; +import ErrorNotification from "../ErrorNotification"; +import HunkExpandDivider from "./HunkExpandDivider"; +import { escapeWhitespace } from "./diffs"; + +const EMPTY_ANNOTATION_FACTORY = {}; + +type Props = DiffFileProps & WithTranslation; + +export type DiffFileProps = DiffObjectProps & { + file: FileDiff; +}; + +type Collapsible = { + collapsed?: boolean; +}; + +type State = Collapsible & { + file: FileDiff; + sideBySide?: boolean; + diffExpander: DiffExpander; + expansionError?: any; +}; + +const DiffFilePanel = styled.div` + /* remove bottom border for collapsed panels */ + ${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")}; +`; + +const FullWidthTitleHeader = styled.div` + max-width: 100%; +`; + +const MarginlessModalContent = styled.div` + margin: -1.25rem; + + & .panel-block { + flex-direction: column; + align-items: stretch; + } +`; + +class DiffFile extends React.Component { + static defaultProps: Partial = { + defaultCollapse: false, + markConflicts: true + }; + + constructor(props: Props) { + super(props); + this.state = { + collapsed: this.defaultCollapse(), + sideBySide: props.sideBySide, + diffExpander: new DiffExpander(props.file), + file: props.file + }; + } + + componentDidUpdate(prevProps: Readonly) { + if (!this.props.isCollapsed && this.props.defaultCollapse !== prevProps.defaultCollapse) { + this.setState({ + collapsed: this.defaultCollapse() + }); + } + } + + defaultCollapse: () => boolean = () => { + const { defaultCollapse, file } = this.props; + if (typeof defaultCollapse === "boolean") { + return defaultCollapse; + } else if (typeof defaultCollapse === "function") { + return defaultCollapse(file.oldPath, file.newPath); + } else { + return false; + } + }; + + toggleCollapse = () => { + const { onCollapseStateChange } = this.props; + const { file } = this.state; + if (this.hasContent(file)) { + if (onCollapseStateChange) { + onCollapseStateChange(file); + } else { + this.setState(state => ({ + collapsed: !state.collapsed + })); + } + } + }; + + toggleSideBySide = (callback: () => void) => { + this.setState( + state => ({ + sideBySide: !state.sideBySide + }), + () => callback() + ); + }; + + setCollapse = (collapsed: boolean) => { + const { onCollapseStateChange } = this.props; + if (onCollapseStateChange) { + onCollapseStateChange(this.state.file, collapsed); + } else { + this.setState({ + collapsed + }); + } + }; + + createHunkHeader = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandHeadRange > 0) { + if (expandableHunk.maxExpandHeadRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } + } + // hunk header must be defined + return ; + }; + + createHunkFooter = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandBottomRange > 0) { + if (expandableHunk.maxExpandBottomRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } + } + // hunk footer must be defined + return ; + }; + + createLastHunkFooter = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandBottomRange !== 0) { + return ( + + {" "} + + + ); + } + // hunk header must be defined + return ; + }; + + expandHead = (expandableHunk: ExpandableHunk, count: number) => { + return () => { + return expandableHunk + .expandHead(count) + .then(this.diffExpanded) + .catch(this.diffExpansionFailed); + }; + }; + + expandBottom = (expandableHunk: ExpandableHunk, count: number) => { + return () => { + return expandableHunk + .expandBottom(count) + .then(this.diffExpanded) + .catch(this.diffExpansionFailed); + }; + }; + + diffExpanded = (newFile: FileDiff) => { + this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) }); + }; + + diffExpansionFailed = (err: any) => { + this.setState({ expansionError: err }); + }; + + collectHunkAnnotations = (hunk: HunkType) => { + const { annotationFactory } = this.props; + const { file } = this.state; + if (annotationFactory) { + return annotationFactory({ + hunk, + file + }); + } else { + return EMPTY_ANNOTATION_FACTORY; + } + }; + + handleClickEvent = (change: Change, hunk: HunkType) => { + const { onClick } = this.props; + const { file } = this.state; + const context = { + changeId: getChangeKey(change), + change, + hunk, + file + }; + if (onClick) { + onClick(context); + } + }; + + createGutterEvents = (hunk: HunkType) => { + const { onClick } = this.props; + if (onClick) { + return { + onClick: (event: ChangeEvent) => { + this.handleClickEvent(event.change, hunk); + } + }; + } + }; + + renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => { + const hunk = expandableHunk.hunk; + if (this.props.markConflicts && hunk.changes) { + this.markConflicts(hunk); + } + const items = []; + if (file._links?.lines) { + items.push(this.createHunkHeader(expandableHunk)); + } else if (i > 0) { + items.push( + +
+
+ ); + } + + items.push( + + ); + if (file._links?.lines) { + if (i === file.hunks!.length - 1) { + items.push(this.createLastHunkFooter(expandableHunk)); + } else { + items.push(this.createHunkFooter(expandableHunk)); + } + } + return items; + }; + + markConflicts = (hunk: HunkType) => { + let inConflict = false; + for (let i = 0; i < hunk.changes.length; ++i) { + if (hunk.changes[i].content === "<<<<<<< HEAD") { + inConflict = true; + } + if (inConflict) { + hunk.changes[i].type = "conflict"; + } + if (hunk.changes[i].content.startsWith(">>>>>>>")) { + inConflict = false; + } + } + }; + + getAnchorId(file: FileDiff) { + let path: string; + if (file.type === "delete") { + path = file.oldPath; + } else { + path = file.newPath; + } + return escapeWhitespace(path); + } + + renderFileTitle = (file: FileDiff) => { + const { t } = this.props; + if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { + return ( + <> + {file.oldPath} {file.newPath} + + ); + } else if (file.type === "delete") { + return file.oldPath; + } + return file.newPath; + }; + + hoverFileTitle = (file: FileDiff): string => { + if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { + return `${file.oldPath} > ${file.newPath}`; + } else if (file.type === "delete") { + return file.oldPath; + } + return file.newPath; + }; + + renderChangeTag = (file: FileDiff) => { + const { t } = this.props; + if (!file.type) { + return; + } + const key = "diff.changes." + file.type; + let value = t(key); + if (key === value) { + value = file.type; + } + + const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info"; + return ( + + ); + }; + + isCollapsed = () => { + const { file, isCollapsed } = this.props; + if (isCollapsed) { + return isCollapsed(file); + } + return this.state.collapsed; + }; + + hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0; + + render() { + const { fileControlFactory, fileAnnotationFactory, t } = this.props; + const { file, sideBySide, diffExpander, expansionError } = this.state; + const viewType = sideBySide ? "split" : "unified"; + const collapsed = this.isCollapsed(); + + const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null; + const innerContent = ( +
+ {fileAnnotations} + + {(hunks: HunkType[]) => + hunks?.map((hunk, n) => { + return this.renderHunk(file, diffExpander.getHunk(n), n); + }) + } + +
+ ); + let icon = ; + let body = null; + if (!collapsed) { + icon = ; + body = innerContent; + } + const collapseIcon = this.hasContent(file) ? icon : null; + const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null; + const modalTitle = file.type === "delete" ? file.oldPath : file.newPath; + const openInFullscreen = file?.hunks?.length ? ( + {innerContent}} + /> + ) : null; + const sideBySideToggle = file?.hunks?.length && ( + + {({ setCollapsed }) => ( + + this.toggleSideBySide(() => { + if (this.state.sideBySide) { + setCollapsed(true); + } + }) + } + /> + )} + + ); + const headerButtons = ( +
+ + {sideBySideToggle} + {openInFullscreen} + {fileControls} + +
+ ); + + let errorModal; + if (expansionError) { + errorModal = ( + this.setState({ expansionError: undefined })} + body={} + active={true} + /> + ); + } + + return ( + + {errorModal} +
+
+ + {collapseIcon} +

+ {this.renderFileTitle(file)} +

+ {this.renderChangeTag(file)} +
+ {headerButtons} +
+
+ {body} +
+ ); + } +} + +export default withTranslation("repos")(DiffFile); diff --git a/scm-ui/ui-legacy/package.json b/scm-ui/ui-legacy/package.json new file mode 100644 index 0000000000..b6b6da726f --- /dev/null +++ b/scm-ui/ui-legacy/package.json @@ -0,0 +1,37 @@ +{ + "name": "@scm-manager/ui-legacy", + "version": "2.31.1-SNAPSHOT", + "private": true, + "main": "src/index.ts", + "scripts": { + "test": "jest", + "lint": "eslint src" + }, + "dependencies": { + "@scm-manager/ui-extensions": "^2.31.1-SNAPSHOT", + "@scm-manager/ui-api": "^2.31.1-SNAPSHOT", + "@scm-manager/ui-types": "^2.31.1-SNAPSHOT", + "redux": "^4.0.0", + "react-redux": "^5.0.7", + "react": "^17.0.1" + }, + "devDependencies": { + "@scm-manager/babel-preset": "^2.12.0", + "@scm-manager/jest-preset": "^2.13.0", + "@scm-manager/prettier-config": "^2.10.1", + "@types/react-redux": "5.0.7", + "@types/react": "^17.0.1" + }, + "babel": { + "presets": [ + "@scm-manager/babel-preset" + ] + }, + "jest": { + "preset": "@scm-manager/jest-preset" + }, + "prettier": "@scm-manager/prettier-config", + "publishConfig": { + "access": "public" + } +} diff --git a/scm-ui/ui-webapp/src/LegacyReduxProvider.tsx b/scm-ui/ui-legacy/src/LegacyReduxProvider.tsx similarity index 82% rename from scm-ui/ui-webapp/src/LegacyReduxProvider.tsx rename to scm-ui/ui-legacy/src/LegacyReduxProvider.tsx index 8c1ffe5ba4..d6bb5c5e26 100644 --- a/scm-ui/ui-webapp/src/LegacyReduxProvider.tsx +++ b/scm-ui/ui-legacy/src/LegacyReduxProvider.tsx @@ -21,10 +21,11 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { createStore } from "redux"; +import { createStore, Reducer } from "redux"; import { IndexResources, Links, Me } from "@scm-manager/ui-types"; import React, { FC } from "react"; import { Provider } from "react-redux"; +import ReduxLegacy from "./ReduxLegacy"; const ACTION_TYPE_INITIAL = "scm/initial"; const ACTION_TYPE_INDEX = "scm/index_success"; @@ -58,9 +59,12 @@ type State = { const initialState: State = {}; -const reducer = (state: State = initialState, action: ActionTypes = { type: ACTION_TYPE_INITIAL }): State => { +const reducer: Reducer = ( + state: State = initialState, + action: ActionTypes = { type: ACTION_TYPE_INITIAL } +): State => { switch (action.type) { - case "scm/index_success": { + case ACTION_TYPE_INDEX: { return { ...state, indexResources: { @@ -69,7 +73,7 @@ const reducer = (state: State = initialState, action: ActionTypes = { type: ACTI } }; } - case "scm/me_success": { + case ACTION_TYPE_ME: { return { ...state, auth: { @@ -85,22 +89,26 @@ const reducer = (state: State = initialState, action: ActionTypes = { type: ACTI // add window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__() as last argument of createStore // to enable redux devtools -const store = createStore(reducer, initialState); +const store = createStore(reducer); -export const fetchIndexResourcesSuccess = (index: IndexResources): ActionTypes => { +export const fetchIndexResourcesSuccess = (index: IndexResources): IndexActionSuccess => { return { type: ACTION_TYPE_INDEX, payload: index }; }; -export const fetchMeSuccess = (me: Me): ActionTypes => { +export const fetchMeSuccess = (me: Me): MeActionSuccess => { return { type: ACTION_TYPE_ME, payload: me }; }; -const LegacyReduxProvider: FC = ({ children }) => {children}; +const LegacyReduxProvider: FC = ({ children }) => ( + + {children} + +); export default LegacyReduxProvider; diff --git a/scm-ui/ui-webapp/src/ReduxAwareApiProvider.tsx b/scm-ui/ui-legacy/src/ReduxLegacy.tsx similarity index 67% rename from scm-ui/ui-webapp/src/ReduxAwareApiProvider.tsx rename to scm-ui/ui-legacy/src/ReduxLegacy.tsx index 9fc61e2038..f86e26748f 100644 --- a/scm-ui/ui-webapp/src/ReduxAwareApiProvider.tsx +++ b/scm-ui/ui-legacy/src/ReduxLegacy.tsx @@ -22,28 +22,29 @@ * SOFTWARE. */ -import React, { FC } from "react"; -import { ApiProvider, ApiProviderProps } from "@scm-manager/ui-api"; -import { IndexResources, Me } from "@scm-manager/ui-types"; - +import React, { FC, useEffect } from "react"; +import { BaseContext, useLegacyContext } from "@scm-manager/ui-api"; import { connect, Dispatch } from "react-redux"; import { ActionTypes, fetchIndexResourcesSuccess, fetchMeSuccess } from "./LegacyReduxProvider"; +import { IndexResources, Me } from "@scm-manager/ui-types"; -const ReduxAwareApiProvider: FC = ({ children, ...listeners }) => ( - {children} -); +const ReduxLegacy: FC = ({ children, onIndexFetched, onMeFetched }) => { + const context = useLegacyContext(); + useEffect(() => { + context.onIndexFetched = onIndexFetched; + context.onMeFetched = onMeFetched; + context.initialize(); + }, [context, onIndexFetched, onMeFetched]); + return <>{children}; +}; const mapDispatchToProps = (dispatch: Dispatch) => { return { - onIndexFetched: (index: IndexResources) => { - dispatch(fetchIndexResourcesSuccess(index)); - }, - onMeFetched: (me: Me) => { - dispatch(fetchMeSuccess(me)); - } + onIndexFetched: (index: IndexResources) => dispatch(fetchIndexResourcesSuccess(index)), + onMeFetched: (me: Me) => dispatch(fetchMeSuccess(me)) }; }; -// eslint-disable-next-line @typescript-eslint/ban-ts-ignore -// @ts-ignore no clue how to type it -export default connect(undefined, mapDispatchToProps)(ReduxAwareApiProvider); +const connector = connect<{}, BaseContext>(undefined, mapDispatchToProps); + +export default connector(ReduxLegacy); diff --git a/scm-ui/ui-legacy/src/index.ts b/scm-ui/ui-legacy/src/index.ts new file mode 100644 index 0000000000..8d38b98ad4 --- /dev/null +++ b/scm-ui/ui-legacy/src/index.ts @@ -0,0 +1,32 @@ +/* + * 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 { binder } from "@scm-manager/ui-extensions"; +import * as Redux from "redux"; +import * as ReactRedux from "react-redux"; +import LegacyReduxProvider from "./LegacyReduxProvider"; + +binder.bind("main.wrapper", LegacyReduxProvider); + +export { Redux, ReactRedux }; diff --git a/scm-ui/ui-legacy/tsconfig.json b/scm-ui/ui-legacy/tsconfig.json new file mode 100644 index 0000000000..7e3ee63a2d --- /dev/null +++ b/scm-ui/ui-legacy/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@scm-manager/tsconfig" +} diff --git a/scm-ui/ui-modules/package.json b/scm-ui/ui-modules/package.json new file mode 100644 index 0000000000..bc34f56b31 --- /dev/null +++ b/scm-ui/ui-modules/package.json @@ -0,0 +1,27 @@ +{ + "name": "@scm-manager/ui-modules", + "version": "2.31.1-SNAPSHOT", + "private": true, + "main": "src/index.ts", + "scripts": { + "test": "jest", + "lint": "eslint src" + }, + "devDependencies": { + "@scm-manager/babel-preset": "^2.12.0", + "@scm-manager/jest-preset":"^2.13.0", + "@scm-manager/prettier-config": "^2.10.1" + }, + "babel": { + "presets": [ + "@scm-manager/babel-preset" + ] + }, + "jest": { + "preset": "@scm-manager/jest-preset" + }, + "prettier": "@scm-manager/prettier-config", + "publishConfig": { + "access": "public" + } +} diff --git a/scm-ui/ui-modules/src/index.ts b/scm-ui/ui-modules/src/index.ts new file mode 100644 index 0000000000..bbea92c15b --- /dev/null +++ b/scm-ui/ui-modules/src/index.ts @@ -0,0 +1,91 @@ +/* + * 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 Module = { + dependencies: string[]; + fn: (...args: unknown[]) => Module; +}; + +const modules: { [name: string]: unknown } = {}; +const lazyModules: { [name: string]: () => Promise } = {}; +const queue: { [name: string]: Module } = {}; + +export const defineLazy = (name: string, cmp: () => Promise) => { + lazyModules[name] = cmp; +}; + +export const defineStatic = (name: string, cmp: unknown) => { + modules[name] = cmp; +}; + +const resolveModule = (name: string) => { + const module = modules[name]; + if (module) { + return Promise.resolve(module); + } + + const lazyModule = lazyModules[name]; + if (lazyModule) { + return lazyModule().then((mod: unknown) => { + modules[name] = mod; + return mod; + }); + } + + return Promise.reject("Could not resolve module: " + name); +}; + +const defineModule = (name: string, module: Module) => { + Promise.all(module.dependencies.map(resolveModule)) + .then(resolvedDependencies => { + delete queue[name]; + + modules["@scm-manager/" + name] = module.fn(...resolvedDependencies); + + Object.keys(queue).forEach(queuedModuleName => { + const queueModule = queue[queuedModuleName]; + defineModule(queuedModuleName, queueModule); + }); + }) + .catch(() => { + queue[name] = module; + }); +}; + +export const define = (name: string, dependencies: string[], fn: (...args: unknown[]) => Module) => { + defineModule(name, { dependencies, fn }); +}; + +export const load = (resource: string) => { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = resource; + script.onload = resolve; + script.onerror = reject; + + const body = document.querySelector("body"); + body?.appendChild(script); + body?.removeChild(script); + }); +}; diff --git a/scm-ui/ui-modules/tsconfig.json b/scm-ui/ui-modules/tsconfig.json new file mode 100644 index 0000000000..7e3ee63a2d --- /dev/null +++ b/scm-ui/ui-modules/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@scm-manager/tsconfig" +} diff --git a/scm-ui/ui-scripts/package.json b/scm-ui/ui-scripts/package.json index 68a783d317..ade1fd1581 100644 --- a/scm-ui/ui-scripts/package.json +++ b/scm-ui/ui-scripts/package.json @@ -28,7 +28,8 @@ }, "devDependencies": { "@scm-manager/eslint-config": "^2.12.0", - "@scm-manager/prettier-config": "^2.10.1" + "@scm-manager/prettier-config": "^2.10.1", + "webpack-bundle-analyzer": "^4.5.0" }, "eslintConfig": { "extends": "@scm-manager/eslint-config", diff --git a/scm-ui/ui-scripts/src/webpack.config.js b/scm-ui/ui-scripts/src/webpack.config.js index 028949a349..de81e02ad4 100644 --- a/scm-ui/ui-scripts/src/webpack.config.js +++ b/scm-ui/ui-scripts/src/webpack.config.js @@ -35,6 +35,13 @@ const root = path.resolve(process.cwd(), "scm-ui"); const babelPlugins = []; const webpackPlugins = []; +if (process.env.ANALYZE_BUNDLES === "true") { + // it is ok to use require here, because we want to load the package conditionally + // eslint-disable-next-line global-require + const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); + webpackPlugins.push(new BundleAnalyzerPlugin()); +} + let mode = "production"; if (isDevelopment) { @@ -49,8 +56,8 @@ if (isDevelopment) { const themedir = path.join(root, "ui-styles", "src"); const themes = fs .readdirSync(themedir) - .map((filename) => path.parse(filename)) - .filter((p) => p.ext === ".scss") + .map(filename => path.parse(filename)) + .filter(p => p.ext === ".scss") .reduce((entries, current) => ({ ...entries, [current.name]: path.join(themedir, current.base) }), {}); console.log(`build ${mode} bundles`); @@ -162,6 +169,7 @@ module.exports = [ }, optimization: { runtimeChunk: "single", + chunkIds: "named", splitChunks: { chunks: "initial", cacheGroups: { diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index a8f6efb62b..84039ff960 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -6,6 +6,7 @@ "@scm-manager/ui-api": "^2.31.1-SNAPSHOT", "@scm-manager/ui-components": "^2.31.1-SNAPSHOT", "@scm-manager/ui-extensions": "^2.31.1-SNAPSHOT", + "@scm-manager/ui-modules": "^2.31.1-SNAPSHOT", "classnames": "^2.2.5", "history": "^4.10.1", "i18next": "^19.6.0", diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 4cd43ea930..df18401217 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -21,36 +21,42 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC } from "react"; +import React, { FC, Suspense } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; import { Links, Me } from "@scm-manager/ui-types"; -import Overview from "../repos/containers/Overview"; -import Users from "../users/containers/Users"; -import Login from "../containers/Login"; -import Logout from "../containers/Logout"; - -import { ErrorBoundary, ProtectedRoute } from "@scm-manager/ui-components"; +import { ErrorBoundary, Loading, ProtectedRoute } from "@scm-manager/ui-components"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; -import CreateUser from "../users/containers/CreateUser"; -import SingleUser from "../users/containers/SingleUser"; -import RepositoryRoot from "../repos/containers/RepositoryRoot"; +// auth routes +const Login = React.lazy(() => import("../containers/Login")); +const Logout = React.lazy(() => import("../containers/Logout")); -import Groups from "../groups/containers/Groups"; -import SingleGroup from "../groups/containers/SingleGroup"; -import CreateGroup from "../groups/containers/CreateGroup"; +// repo routes +const Overview = React.lazy(() => import("../repos/containers/Overview")); +const RepositoryRoot = React.lazy(() => import("../repos/containers/RepositoryRoot")); +const NamespaceRoot = React.lazy(() => import("../repos/namespaces/containers/NamespaceRoot")); +const CreateRepositoryRoot = React.lazy(() => import("../repos/containers/CreateRepositoryRoot")); -import Admin from "../admin/containers/Admin"; +// user routes +const Users = React.lazy(() => import("../users/containers/Users")); +const CreateUser = React.lazy(() => import("../users/containers/CreateUser")); +const SingleUser = React.lazy(() => import("../users/containers/SingleUser")); + +const Groups = React.lazy(() => import("../groups/containers/Groups")); +const SingleGroup = React.lazy(() => import("../groups/containers/SingleGroup")); +const CreateGroup = React.lazy(() => import("../groups/containers/CreateGroup")); + +const Admin = React.lazy(() => import("../admin/containers/Admin")); + +const Profile = React.lazy(() => import("./Profile")); + +const ImportLog = React.lazy(() => import("../repos/importlog/ImportLog")); +const Search = React.lazy(() => import("../search/Search")); +const Syntax = React.lazy(() => import("../search/Syntax")); +const ExternalError = React.lazy(() => import("./ExternalError")); -import Profile from "./Profile"; -import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot"; -import ImportLog from "../repos/importlog/ImportLog"; -import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot"; -import Search from "../search/Search"; -import Syntax from "../search/Syntax"; -import ExternalError from "./ExternalError"; type Props = { me: Me; @@ -74,38 +80,40 @@ const Main: FC = props => { return (
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
); diff --git a/scm-ui/ui-webapp/src/containers/PluginLoader.tsx b/scm-ui/ui-webapp/src/containers/PluginLoader.tsx index 9a7395f444..c3af224985 100644 --- a/scm-ui/ui-webapp/src/containers/PluginLoader.tsx +++ b/scm-ui/ui-webapp/src/containers/PluginLoader.tsx @@ -24,8 +24,9 @@ import React, { ReactNode } from "react"; import classNames from "classnames"; import styled from "styled-components"; -import { apiClient, Loading, ErrorNotification, ErrorBoundary, Icon } from "@scm-manager/ui-components"; +import { apiClient, ErrorBoundary, ErrorNotification, Icon, Loading } from "@scm-manager/ui-components"; import loadBundle from "./loadBundle"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { loaded: boolean; @@ -88,18 +89,10 @@ class PluginLoader extends React.Component { for (const plugin of sortedPlugins) { promises.push(this.loadPlugin(plugin)); } - return promises.reduce((chain, current) => { - return chain.then(chainResults => { - return current.then(currentResult => [...chainResults, currentResult]); - }); - }, Promise.resolve([])); + return Promise.all(promises); }; loadPlugin = (plugin: Plugin) => { - this.setState({ - message: `loading ${plugin.name}` - }); - const promises = []; for (const bundle of plugin.bundles) { promises.push( @@ -137,11 +130,16 @@ class PluginLoader extends React.Component { } if (loaded) { - return
{this.props.children}
; + return ( + + {this.props.children} + + ); } return ; } } + const comparePluginsByName = (a: Plugin, b: Plugin) => { if (a.name < b.name) { return -1; diff --git a/scm-ui/ui-webapp/src/containers/loadBundle.ts b/scm-ui/ui-webapp/src/containers/loadBundle.ts index ea8a6f955c..8044ab4c77 100644 --- a/scm-ui/ui-webapp/src/containers/loadBundle.ts +++ b/scm-ui/ui-webapp/src/containers/loadBundle.ts @@ -22,115 +22,44 @@ * SOFTWARE. */ -/* global SystemJS */ -// eslint-disable-next-line import/no-webpack-loader-syntax -import "script-loader!../../../../node_modules/systemjs/dist/system.js"; +import { define, defineLazy, defineStatic, load } from "@scm-manager/ui-modules"; import * as React from "react"; import * as ReactDOM from "react-dom"; import * as ReactRouterDom from "react-router-dom"; -import * as Redux from "redux"; -import * as ReactRedux from "react-redux"; -import ReactQueryDefault, * as ReactQuery from "react-query"; -import StyledComponentsDefault, * as StyledComponents from "styled-components"; -import ReactHookFormDefault, * as ReactHookForm from "react-hook-form"; +import * as ReactQuery from "react-query"; +import * as StyledComponents from "styled-components"; +import * as ReactHookForm from "react-hook-form"; import * as ReactI18Next from "react-i18next"; -import ClassNamesDefault, * as ClassNames from "classnames"; -import QueryStringDefault, * as QueryString from "query-string"; +import * as ClassNames from "classnames"; +import * as QueryString from "query-string"; import * as UIExtensions from "@scm-manager/ui-extensions"; import * as UIComponents from "@scm-manager/ui-components"; -import { urls } from "@scm-manager/ui-components"; import * as UIApi from "@scm-manager/ui-api"; -type PluginModule = { - name: string; - address: string; -}; - -const BundleLoader = { - name: "bundle-loader", - fetch: (plugin: PluginModule) => { - return fetch(plugin.address, { - credentials: "same-origin", - headers: { - Cache: "no-cache", - // identify the request as ajax request - "X-Requested-With": "XMLHttpRequest" - } - }).then(response => { - return response.text(); - }); +declare global { + interface Window { + define: typeof define; } -}; +} -SystemJS.registry.set(BundleLoader.name, SystemJS.newModule(BundleLoader)); +window.define = define; -SystemJS.config({ - baseURL: urls.withContextPath("/assets"), - meta: { - "/*": { - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore typing missing, but seems required - esModule: true, - authorization: true, - loader: BundleLoader.name - } - } -}); +defineStatic("react", React); +defineStatic("react-dom", ReactDOM); +defineStatic("react-router-dom", ReactRouterDom); +defineStatic("styled-components", StyledComponents); +defineStatic("react-i18next", ReactI18Next); +defineStatic("react-hook-form", ReactHookForm); +defineStatic("react-query", ReactQuery); +defineStatic("classnames", ClassNames); +defineStatic("query-string", QueryString); +defineStatic("@scm-manager/ui-extensions", UIExtensions); +defineStatic("@scm-manager/ui-components", UIComponents); +defineStatic("@scm-manager/ui-api", UIApi); -// We have to patch the resolve methods of SystemJS -// in order to resolve the correct bundle url for plugins +// redux is deprecated in favor of ui-api +defineLazy("redux", () => import("@scm-manager/ui-legacy").then(legacy => legacy.Redux)); +defineLazy("react-redux", () => import("@scm-manager/ui-legacy").then(legacy => legacy.ReactRedux)); -const resolveModuleUrl = (key: string) => { - if (key.startsWith("@scm-manager/scm-") && key.endsWith("-plugin")) { - const pluginName = key.replace("@scm-manager/", ""); - return urls.withContextPath(`/assets/${pluginName}.bundle.js`); - } - return key; -}; - -const defaultResolve = SystemJS.resolve; -SystemJS.resolve = function(key, parentName) { - const module = resolveModuleUrl(key); - return defaultResolve.apply(this, [module, parentName]); -}; - -const defaultResolveSync = SystemJS.resolveSync; -SystemJS.resolveSync = function(key, parentName) { - const module = resolveModuleUrl(key); - return defaultResolveSync.apply(this, [module, parentName]); -}; - -//eslint-disable-next-line @typescript-eslint/no-explicit-any -const expose = (name: string, cmp: any, defaultCmp?: any) => { - let mod = cmp; - if (defaultCmp) { - // SystemJS default export: - // https://github.com/systemjs/systemjs/issues/1749 - mod = { - ...cmp, - __useDefault: defaultCmp - }; - } - SystemJS.set(name, SystemJS.newModule(mod)); -}; - -expose("react", React); -expose("react-dom", ReactDOM); -expose("react-router-dom", ReactRouterDom); -expose("styled-components", StyledComponents, StyledComponentsDefault); -expose("react-i18next", ReactI18Next); -expose("react-hook-form", ReactHookForm, ReactHookFormDefault); -expose("react-query", ReactQuery, ReactQueryDefault); -expose("classnames", ClassNames, ClassNamesDefault); -expose("query-string", QueryString, QueryStringDefault); -expose("@scm-manager/ui-extensions", UIExtensions); -expose("@scm-manager/ui-components", UIComponents); -expose("@scm-manager/ui-api", UIApi); - -// redux is deprecated in favor of ui-api, -// which will be exported soon -expose("redux", Redux); -expose("react-redux", ReactRedux); - -export default (plugin: string) => SystemJS.import(plugin); +export default load; diff --git a/scm-ui/ui-webapp/src/index.tsx b/scm-ui/ui-webapp/src/index.tsx index 009fc77fe7..26a6363f3a 100644 --- a/scm-ui/ui-webapp/src/index.tsx +++ b/scm-ui/ui-webapp/src/index.tsx @@ -35,8 +35,7 @@ import { binder } from "@scm-manager/ui-extensions"; import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink"; import "./tokenExpired"; -import LegacyReduxProvider from "./LegacyReduxProvider"; -import ReduxAwareApiProvider from "./ReduxAwareApiProvider"; +import { ApiProvider } from "@scm-manager/ui-api"; binder.bind("changeset.description.tokens", ChangesetShortLink); @@ -46,14 +45,12 @@ if (!root) { } ReactDOM.render( - - - - - - - - - , + + + + + + + , root ); diff --git a/yarn.lock b/yarn.lock index 46a893c3b8..24745ea623 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3151,6 +3151,11 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@polka/url@^1.0.0-next.20": + version "1.0.0-next.21" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" + integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== + "@popperjs/core@^2.5.4", "@popperjs/core@^2.6.0": version "2.9.2" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353" @@ -5188,6 +5193,11 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + acorn@^5.5.3: version "5.7.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e" @@ -5203,6 +5213,11 @@ acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.4: + version "8.6.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.6.0.tgz#e3692ba0eb1a0c83eaa4f37f5fa7368dd7142895" + integrity sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw== + acorn@^8.1.0: version "8.2.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.2.4.tgz#caba24b08185c3b56e3168e97d15ed17f4d31fd0" @@ -8976,7 +8991,7 @@ duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2: dependencies: readable-stream "^2.0.2" -duplexer@^0.1.1: +duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -10879,6 +10894,13 @@ gzip-size@5.1.1: duplexer "^0.1.1" pify "^4.0.1" +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== + dependencies: + duplexer "^0.1.2" + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -14891,6 +14913,11 @@ mri@1.1.4: resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" integrity sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w== +mrmime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.0.tgz#14d387f0585a5233d291baba339b063752a2398b" + integrity sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -15539,6 +15566,11 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259" integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q== +opener@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + openurl@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/openurl/-/openurl-1.1.1.tgz#3875b4b0ef7a52c156f0db41d4609dbb0f94b387" @@ -18781,6 +18813,15 @@ simplebar@^4.2.3: lodash.throttle "^4.1.1" resize-observer-polyfill "^1.5.1" +sirv@^1.0.7: + version "1.0.19" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" + integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ== + dependencies: + "@polka/url" "^1.0.0-next.20" + mrmime "^1.0.0" + totalist "^1.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -20096,6 +20137,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +totalist@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" + integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== + touch@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" @@ -21053,6 +21099,21 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webpack-bundle-analyzer@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5" + integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ== + dependencies: + acorn "^8.0.4" + acorn-walk "^8.0.0" + chalk "^4.1.0" + commander "^7.2.0" + gzip-size "^6.0.0" + lodash "^4.17.20" + opener "^1.5.2" + sirv "^1.0.7" + ws "^7.3.1" + webpack-cli@^4.9.1: version "4.9.1" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.9.1.tgz#b64be825e2d1b130f285c314caa3b1ba9a4632b3" @@ -21592,6 +21653,11 @@ ws@^5.2.0: dependencies: async-limiter "~1.0.0" +ws@^7.3.1: + version "7.5.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" + integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== + ws@^7.4.4: version "7.4.5" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1"