From 4ec01ff4d1abc79191ef0c50752a703b5fff124a Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 3 Mar 2021 11:00:26 +0100 Subject: [PATCH] Fix endless loading spinner for sources of empty repositories (#1565) --- .../fix_loading_source_of_empty_repo.yaml | 2 + scm-ui/ui-api/src/sources.test.ts | 2 +- scm-ui/ui-types/src/Sources.ts | 4 +- scm-ui/ui-webapp/public/locales/de/repos.json | 3 +- scm-ui/ui-webapp/public/locales/en/repos.json | 3 +- .../codeSection/containers/CodeOverview.tsx | 8 +- .../src/repos/sources/components/FileTree.tsx | 9 +- .../src/repos/sources/containers/Sources.tsx | 38 ++++-- .../src/repos/sources/utils/files.test.ts | 111 ++++++++++++++++++ .../src/repos/sources/utils/files.ts | 44 +++++++ 10 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 gradle/changelog/fix_loading_source_of_empty_repo.yaml create mode 100644 scm-ui/ui-webapp/src/repos/sources/utils/files.test.ts create mode 100644 scm-ui/ui-webapp/src/repos/sources/utils/files.ts diff --git a/gradle/changelog/fix_loading_source_of_empty_repo.yaml b/gradle/changelog/fix_loading_source_of_empty_repo.yaml new file mode 100644 index 0000000000..fb6b22ce5e --- /dev/null +++ b/gradle/changelog/fix_loading_source_of_empty_repo.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Fix endless loading spinner for sources of empty repositories ([#1565](https://github.com/scm-manager/scm-manager/issues/1565)) diff --git a/scm-ui/ui-api/src/sources.test.ts b/scm-ui/ui-api/src/sources.test.ts index 4a08387983..b6da239e2c 100644 --- a/scm-ui/ui-api/src/sources.test.ts +++ b/scm-ui/ui-api/src/sources.test.ts @@ -120,7 +120,7 @@ describe("Test sources hooks", () => { }); const firstChild = (directory?: File) => { - if (directory?._embedded.children && directory._embedded.children.length > 0) { + if (directory?._embedded?.children && directory._embedded.children.length > 0) { return directory._embedded.children[0]; } }; diff --git a/scm-ui/ui-types/src/Sources.ts b/scm-ui/ui-types/src/Sources.ts index b6b58b516a..c5b659b463 100644 --- a/scm-ui/ui-types/src/Sources.ts +++ b/scm-ui/ui-types/src/Sources.ts @@ -43,7 +43,7 @@ export type File = { computationAborted?: boolean; truncated?: boolean; _links: Links; - _embedded: { - children: File[] | null | undefined; + _embedded?: { + children?: File[] | null; }; }; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index c3e8f00ce1..9429bfbae0 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -195,7 +195,8 @@ "code": { "sources": "Sources", "commits": "Commits", - "branchSelector": "Branches" + "branchSelector": "Branches", + "noBranches": "Keine Sources für das Repository gefunden." }, "changesets": { "errorTitle": "Fehler", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 200b0338f1..1b497cd187 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -195,7 +195,8 @@ "code": { "sources": "Sources", "commits": "Commits", - "branchSelector": "Branches" + "branchSelector": "Branches", + "noBranches": "No sources found for this repository." }, "changesets": { "errorTitle": "Error", diff --git a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx index b88d8f2320..9834dcc8af 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx @@ -26,7 +26,7 @@ import { Route, useLocation } from "react-router-dom"; import Sources from "../../sources/containers/Sources"; import ChangesetsRoot from "../../containers/ChangesetsRoot"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { ErrorPage, Loading } from "@scm-manager/ui-components"; +import { ErrorPage, Loading, Notification } from "@scm-manager/ui-components"; import { useTranslation } from "react-i18next"; import { useBranches } from "@scm-manager/ui-api"; @@ -57,7 +57,7 @@ const CodeOverviewWithBranches: FC = ({ repository, baseUrl }) => { const { isLoading, error, data } = useBranches(repository); const selectedBranch = useSelectedBranch(); const [t] = useTranslation("repos"); - const branches = data?._embedded.branches; + const branches = data?._embedded.branches || []; if (isLoading) { return ; @@ -69,6 +69,10 @@ const CodeOverviewWithBranches: FC = ({ repository, baseUrl }) => { ); } + if (branches.length === 0) { + return {t("code.noBranches")}; + } + return ; }; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx index 767bc95ae7..89d1a3f968 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx @@ -30,6 +30,7 @@ import { File } from "@scm-manager/ui-types"; import { Notification } from "@scm-manager/ui-components"; import FileTreeLeaf from "./FileTreeLeaf"; import TruncatedNotification from "./TruncatedNotification"; +import {isRootPath} from "../utils/files"; type Props = { directory: File; @@ -60,7 +61,7 @@ const FileTree: FC = ({ directory, baseUrl, revision, fetchNextPage, isFe const { path } = directory; const files: File[] = []; - if (path) { + if (!isRootPath(path)) { files.push({ name: "..", path: findParent(path), @@ -73,14 +74,10 @@ const FileTree: FC = ({ directory, baseUrl, revision, fetchNextPage, isFe }); } - files.push(...(directory._embedded.children || [])); + files.push(...(directory._embedded?.children || [])); const baseUrlWithRevision = baseUrl + "/" + encodeURIComponent(revision); - if (!files || files.length === 0) { - return {t("sources.noSources")}; - } - return (
diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx index 2653462dbb..6be23fd736 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx @@ -23,13 +23,15 @@ */ import React, { FC, useEffect } from "react"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { Breadcrumb, ErrorNotification, Loading } from "@scm-manager/ui-components"; +import { Breadcrumb, ErrorNotification, Loading, Notification } from "@scm-manager/ui-components"; import FileTree from "../components/FileTree"; import Content from "./Content"; import CodeActionBar from "../../codeSection/components/CodeActionBar"; import replaceBranchWithRevision from "../ReplaceBranchWithRevision"; import { useSources } from "@scm-manager/ui-api"; import { useHistory, useLocation, useParams } from "react-router-dom"; +import { isEmptyDirectory, isRootFile } from "../utils/files"; +import { useTranslation } from "react-i18next"; type Props = { repository: Repository; @@ -55,6 +57,7 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) = const { revision, path } = useUrlParams(); const history = useHistory(); const location = useLocation(); + const [t] = useTranslation("repos"); // redirect to default branch is non branch selected useEffect(() => { if (branches && branches.length > 0 && !selectedBranch) { @@ -118,15 +121,16 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) = }; if (file.directory) { - return ( - <> - -
+ let body; + if (isRootFile(file) && isEmptyDirectory(file)) { + body = ( +
+ {t("sources.noSources")} +
+ ); + } else { + body = ( + <> {renderBreadcrumb()} = ({ repository, branches, selectedBranch, baseUrl }) = isFetchingNextPage={isFetchingNextPage} fetchNextPage={fetchNextPage} /> -
+ + ); + } + + return ( + <> + +
{body}
); } else { diff --git a/scm-ui/ui-webapp/src/repos/sources/utils/files.test.ts b/scm-ui/ui-webapp/src/repos/sources/utils/files.test.ts new file mode 100644 index 0000000000..74c7203ae2 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/utils/files.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { File } from "@scm-manager/ui-types"; +import { isEmptyDirectory, isRootFile, isRootPath } from "./files"; + +describe("files tests", () => { + const createRootDirectory = (): File => { + return { + name: "", + path: "/", + revision: "42", + directory: true, + _links: {}, + _embedded: { + children: [] + } + }; + }; + + const readme: File = { + name: "README.md", + path: "README.md", + revision: "42", + directory: false, + _links: {} + }; + + describe("isRootPath tests", () => { + it("should return false", () => { + const paths = ["a", "b/c", "/a"]; + for (const p of paths) { + expect(isRootPath(p)).toBe(false); + } + }); + + it("should return true", () => { + const paths = ["", "/"]; + for (const p of paths) { + expect(isRootPath(p)).toBe(true); + } + }); + }); + + describe("isRootFile tests", () => { + it("should return false, if it is not a directory", () => { + const file = createRootDirectory(); + file.directory = false; + expect(isRootFile(file)).toBe(false); + }); + + it("should return true", () => { + const directory = createRootDirectory(); + expect(isRootFile(directory)).toBe(true); + }); + }); + + describe("isEmptyDirectory tests", () => { + it("should return false, if it is not a directory", () => { + const directory = createRootDirectory(); + directory.directory = false; + directory._embedded.children = [readme]; + expect(isEmptyDirectory(directory)).toBe(false); + }); + + it("should return false, if it is not empty", () => { + const directory = createRootDirectory(); + directory._embedded.children = [readme]; + expect(isEmptyDirectory(directory)).toBe(false); + }); + + it("should return true, if children is empty", () => { + const directory = createRootDirectory(); + expect(isEmptyDirectory(directory)).toBe(true); + }); + + it("should return true, if children is undefined", () => { + const directory = createRootDirectory(); + directory._embedded.children = undefined; + expect(isEmptyDirectory(directory)).toBe(true); + }); + + it("should return true, if _embedded is undefined", () => { + const directory = createRootDirectory(); + directory._embedded = undefined; + expect(isEmptyDirectory(directory)).toBe(true); + }); + }); +}); diff --git a/scm-ui/ui-webapp/src/repos/sources/utils/files.ts b/scm-ui/ui-webapp/src/repos/sources/utils/files.ts new file mode 100644 index 0000000000..a7da5ccb07 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/utils/files.ts @@ -0,0 +1,44 @@ +/* + * 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 { File } from "@scm-manager/ui-types"; + +export const isRootPath = (path: string) => { + return path === "" || path === "/"; +}; + +export const isRootFile = (file: File) => { + if (!file.directory) { + return false; + } + return isRootPath(file.path); +}; + +export const isEmptyDirectory = (file: File) => { + if (!file.directory) { + return false; + } + return (file._embedded?.children?.length || 0) === 0; +};