diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/history.ts b/scm-ui/ui-api/src/annotations.ts similarity index 64% rename from scm-ui/ui-webapp/src/repos/sources/containers/history.ts rename to scm-ui/ui-api/src/annotations.ts index 2468182dcf..c745857f73 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/history.ts +++ b/scm-ui/ui-api/src/annotations.ts @@ -21,27 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import { AnnotatedSource, File, Link, Repository } from "@scm-manager/ui-types"; +import { useQuery } from "react-query"; +import { apiClient } from "./apiclient"; +import { ApiResult } from "./base"; +import { repoQueryKey } from "./keys"; -import { apiClient } from "@scm-manager/ui-components"; - -export function getHistory(url: string) { - return apiClient - .get(url) - .then(response => response.json()) - .then(result => { - return { - changesets: result._embedded.changesets, - pageCollection: { - _embedded: result._embedded, - _links: result._links, - page: result.page, - pageTotal: result.pageTotal - } - }; - }) - .catch(err => { - return { - error: err - }; - }); -} +export const useAnnotations = (repository: Repository, revision: string, file: File): ApiResult => { + const { isLoading, error, data } = useQuery( + repoQueryKey(repository, "annotations", revision, file.path), + () => apiClient.get((file._links.annotate as Link).href).then((response) => response.json()) + ); + return { + isLoading, + error, + data, + }; +}; diff --git a/scm-ui/ui-api/src/changesets.ts b/scm-ui/ui-api/src/changesets.ts index a20160f465..dfd4d43aaa 100644 --- a/scm-ui/ui-api/src/changesets.ts +++ b/scm-ui/ui-api/src/changesets.ts @@ -34,7 +34,7 @@ type UseChangesetsRequest = { page?: string | number; }; -const changesetQueryKey = (repository: NamespaceAndName, id: string) => { +export const changesetQueryKey = (repository: NamespaceAndName, id: string) => { return repoQueryKey(repository, "changeset", id); }; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/contentType.ts b/scm-ui/ui-api/src/contentType.ts similarity index 64% rename from scm-ui/ui-webapp/src/repos/sources/containers/contentType.ts rename to scm-ui/ui-api/src/contentType.ts index 62926ac5e0..156fa96ec8 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/contentType.ts +++ b/scm-ui/ui-api/src/contentType.ts @@ -21,21 +21,29 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -import { apiClient } from "@scm-manager/ui-components"; +import { apiClient } from "./apiclient"; +import { useQuery } from "react-query"; +import { ApiResult } from "./base"; export type ContentType = { - type : string; + type: string; language?: string; +}; + +function getContentType(url: string): Promise { + return apiClient.head(url).then((response) => { + return { + type: response.headers.get("Content-Type") || "application/octet-stream", + language: response.headers.get("X-Programming-Language") || undefined, + }; + }); } -export function getContentType(url: string) : Promise { - return apiClient - .head(url) - .then(response => { - return { - type: response.headers.get("Content-Type") || "application/octet-stream", - language: response.headers.get("X-Programming-Language") || undefined - }; - }) -} +export const useContentType = (url: string): ApiResult => { + const { isLoading, error, data } = useQuery(["contentType", url], () => getContentType(url)); + return { + isLoading, + error, + data, + }; +}; diff --git a/scm-ui/ui-api/src/history.ts b/scm-ui/ui-api/src/history.ts new file mode 100644 index 0000000000..1a460fd47e --- /dev/null +++ b/scm-ui/ui-api/src/history.ts @@ -0,0 +1,62 @@ +/* + * 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 { ApiResult } from "./base"; +import { Changeset, ChangesetCollection, File, Link, Repository } from "@scm-manager/ui-types"; +import { useQuery, useQueryClient } from "react-query"; +import { apiClient } from "./apiclient"; +import { createQueryString } from "./utils"; +import { changesetQueryKey } from "./changesets"; +import { repoQueryKey } from "./keys"; + +export type UseHistoryRequest = { + page?: number | string; +}; + +export const useHistory = ( + repository: Repository, + revision: string, + file: File, + request?: UseHistoryRequest +): ApiResult => { + const queryClient = useQueryClient(); + const link = (file._links.history as Link).href; + + const queryParams: Record = {}; + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + repoQueryKey(repository, "history", revision, file.path, request?.page || 0), + () => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()), + { + keepPreviousData: true, + onSuccess: (changesets: ChangesetCollection) => { + changesets._embedded.changesets.forEach((changeset: Changeset) => + queryClient.setQueryData(changesetQueryKey(repository, changeset.id), changeset) + ); + }, + } + ); +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 410cb85045..9a46f03692 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -50,6 +50,9 @@ export * from "./import"; export * from "./diff"; export * from "./notifications"; export * from "./configLink"; +export * from "./history"; +export * from "./contentType"; +export * from "./annotations"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/AnnotateView.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/AnnotateView.tsx index de9954ba44..789a82d45b 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/AnnotateView.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/AnnotateView.tsx @@ -22,46 +22,39 @@ * SOFTWARE. */ -import React, { FC, useEffect, useState } from "react"; -import { Link, Repository, File, AnnotatedSource } from "@scm-manager/ui-types"; -import { Annotate, apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components"; -import { getContentType } from "./contentType"; +import React, { FC } from "react"; +import { File, Link, Repository } from "@scm-manager/ui-types"; +import { Annotate, ErrorNotification, Loading } from "@scm-manager/ui-components"; +import { useAnnotations, useContentType } from "@scm-manager/ui-api"; type Props = { file: File; repository: Repository; + revision: string; }; -const AnnotateView: FC = ({ file, repository }) => { - const [annotation, setAnnotation] = useState(undefined); - const [language, setLanguage] = useState(undefined); - const [error, setError] = useState(undefined); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const languagePromise = getContentType((file._links.self as Link).href).then(result => - setLanguage(result.language) - ); - - const apiClientPromise = apiClient - .get((file._links.annotate as Link).href) - .then(response => response.json()) - .then(setAnnotation); - - Promise.all([languagePromise, apiClientPromise]) - .then(() => setLoading(false)) - .catch(setError); - }, [file]); +const AnnotateView: FC = ({ file, repository, revision }) => { + const { + data: annotation, + isLoading: isAnnotationLoading, + error: annotationLoadError, + } = useAnnotations(repository, revision, file); + const { + data: contentType, + isLoading: isContentTypeLoading, + error: contentTypeLoadError, + } = useContentType((file._links.self as Link).href); + const error = annotationLoadError || contentTypeLoadError; if (error) { return ; } - if (!annotation || loading) { + if (isAnnotationLoading || isContentTypeLoading || !annotation || !contentType) { return ; } - return ; + return ; }; export default AnnotateView; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx index 499e536e1a..d20604f9cd 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx @@ -37,7 +37,6 @@ type Props = { file: File; repository: Repository; revision: string; - path: string; breadcrumb: React.ReactNode; error?: Error; }; @@ -78,7 +77,7 @@ const BorderLessDiv = styled.div` export type SourceViewSelection = "source" | "annotations" | "history"; -const Content: FC = ({ file, repository, revision, path, breadcrumb, error }) => { +const Content: FC = ({ file, repository, revision, breadcrumb, error }) => { const [t] = useTranslation("repos"); const [collapsed, setCollapsed] = useState(true); const [selected, setSelected] = useState("source"); @@ -215,13 +214,13 @@ const Content: FC = ({ file, repository, revision, path, breadcrumb, erro let body; switch (selected) { case "source": - body = ; + body = ; break; case "annotations": - body = ; + body = ; break; case "history": - body = ; + body = ; } const header = showHeader(body); const moreInformation = showMoreInformation(); diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/HistoryView.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/HistoryView.tsx index 22d196f6d2..d4d8c95307 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/HistoryView.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/HistoryView.tsx @@ -21,115 +21,39 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { Changeset, File, PagedCollection, Repository, Link } from "@scm-manager/ui-types"; +import React, { FC, useState } from "react"; +import { File, Repository } from "@scm-manager/ui-types"; import { ChangesetList, ErrorNotification, Loading, StatePaginator } from "@scm-manager/ui-components"; -import { getHistory } from "./history"; +import { useHistory } from "@scm-manager/ui-api"; type Props = { file: File; repository: Repository; + revision: string; }; -type State = { - loaded: boolean; - changesets: Changeset[]; - page: number; - pageCollection?: PagedCollection; - error?: Error; - currentRevision: string; +const HistoryView: FC = ({ repository, file, revision }) => { + const [page, setPage] = useState(0); + const { error, isLoading, data: history } = useHistory(repository, revision, file, { page }); + + if (!history || isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + <> +
+ +
+
+ setPage(newPage - 1)} /> +
+ + ); }; -class HistoryView extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - loaded: false, - page: 1, - changesets: [], - currentRevision: "" - }; - } - - componentDidMount() { - const { file } = this.props; - if (file) { - this.updateHistory((file._links.history as Link).href); - } - } - - componentDidUpdate() { - const { file } = this.props; - const { currentRevision } = this.state; - if (file?.revision !== currentRevision) { - this.updateHistory((file._links.history as Link).href); - } - } - - updateHistory(link: string) { - const { file } = this.props; - getHistory(link) - .then(result => { - this.setState({ - ...this.state, - loaded: true, - changesets: result.changesets, - pageCollection: result.pageCollection, - page: result.pageCollection.page, - currentRevision: file.revision - }); - }) - .catch(error => - this.setState({ - ...this.state, - error, - loaded: true - }) - ); - } - - updatePage(page: number) { - const { file } = this.props; - const internalPage = page - 1; - this.updateHistory((file._links.history as Link).href + "?page=" + internalPage.toString()); - } - - showHistory() { - const { repository } = this.props; - const { changesets, page, pageCollection } = this.state; - const currentPage = page + 1; - return ( - <> -
- -
-
- this.updatePage(newPage)} - /> -
- - ); - } - - render() { - const { file } = this.props; - const { loaded, error } = this.state; - - if (!file || !loaded) { - return ; - } - if (error) { - return ; - } - - const history = this.showHistory(); - - return <>{history}; - } -} - export default HistoryView; 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 84323e6af3..6375436bec 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx @@ -176,7 +176,6 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) = file={file} repository={repository} revision={revision || file.revision} - path={path} breadcrumb={renderBreadcrumb()} error={error} /> diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx index 91ceb154ce..71fec326c3 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx @@ -21,16 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; + +import React, { FC } from "react"; import SourcecodeViewer from "../components/content/SourcecodeViewer"; import ImageViewer from "../components/content/ImageViewer"; import DownloadViewer from "../components/content/DownloadViewer"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import { getContentType } from "./contentType"; -import { File, Repository } from "@scm-manager/ui-types"; +import { File, Link, Repository } from "@scm-manager/ui-types"; import { ErrorNotification, Loading } from "@scm-manager/ui-components"; import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer"; import styled from "styled-components"; +import { useContentType } from "@scm-manager/ui-api"; const NoSpacingSyntaxHighlighterContainer = styled.div` & pre { @@ -43,96 +44,48 @@ type Props = { repository: Repository; file: File; revision: string; - path: string; }; -type State = { - contentType: string; - language: string; - loaded: boolean; - error?: Error; +const SourcesView: FC = ({ file, repository, revision }) => { + const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href); + + if (error) { + return ; + } + + if (!contentTypeData || isLoading) { + return ; + } + + let sources; + + const { type: contentType, language } = contentTypeData; + const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`; + if (contentType.startsWith("image/")) { + sources = ; + } else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) { + sources = ; + } else if (language) { + sources = ; + } else if (contentType.startsWith("text/")) { + sources = ; + } else { + sources = ( + + + + ); + } + + return {sources}; }; -class SourcesView extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - contentType: "", - language: "", - loaded: false, - }; - } - - componentDidMount() { - const { file } = this.props; - getContentType(file._links.self.href) - .then((result) => { - this.setState({ - ...this.state, - contentType: result.type, - language: result.language, - loaded: true, - }); - }) - .catch((error) => { - this.setState({ - ...this.state, - error, - loaded: true, - }); - }); - } - - createBasePath() { - const { repository, revision } = this.props; - return `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`; - } - - showSources() { - const { file, revision } = this.props; - const { contentType, language } = this.state; - const basePath = this.createBasePath(); - if (contentType.startsWith("image/")) { - return ; - } else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) { - return ; - } else if (language) { - return ; - } else if (contentType.startsWith("text/")) { - return ; - } else { - return ( - - - - ); - } - } - - render() { - const { file } = this.props; - const { loaded, error } = this.state; - - if (!file || !loaded) { - return ; - } - if (error) { - return ; - } - - const sources = this.showSources(); - - return {sources}; - } -} - export default SourcesView; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/contentType.test.ts b/scm-ui/ui-webapp/src/repos/sources/containers/contentType.test.ts deleted file mode 100644 index b6834b9b2c..0000000000 --- a/scm-ui/ui-webapp/src/repos/sources/containers/contentType.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 fetchMock from "fetch-mock"; -import { getContentType } from "./contentType"; - -describe("get content type", () => { - const CONTENT_URL = "/repositories/scmadmin/TestRepo/content/testContent"; - - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); - - it("should return content", done => { - const headers = { - "Content-Type": "application/text", - "X-Programming-Language": "JAVA" - }; - - fetchMock.head("/api/v2" + CONTENT_URL, { - headers - }); - - getContentType(CONTENT_URL).then(content => { - expect(content.type).toBe("application/text"); - expect(content.language).toBe("JAVA"); - done(); - }); - }); -}); diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/history.test.ts b/scm-ui/ui-webapp/src/repos/sources/containers/history.test.ts deleted file mode 100644 index 91c7d85a9b..0000000000 --- a/scm-ui/ui-webapp/src/repos/sources/containers/history.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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 fetchMock from "fetch-mock"; -import { getHistory } from "./history"; - -describe("get content type", () => { - const FILE_URL = "/repositories/scmadmin/TestRepo/history/file"; - - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); - - const history = { - page: 0, - pageTotal: 10, - _links: { - self: { - href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10" - }, - first: { - href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10" - }, - next: { - href: "/repositories/scmadmin/TestRepo/history/file?page=1&pageSize=10" - }, - last: { - href: "/repositories/scmadmin/TestRepo/history/file?page=9&pageSize=10" - } - }, - _embedded: { - changesets: [ - { - id: "1234" - }, - { - id: "2345" - } - ] - } - }; - - it("should return history", done => { - fetchMock.get("/api/v2" + FILE_URL, history); - - getHistory(FILE_URL).then(content => { - expect(content.changesets).toEqual(history._embedded.changesets); - expect(content.pageCollection.page).toEqual(history.page); - expect(content.pageCollection.pageTotal).toEqual(history.pageTotal); - expect(content.pageCollection._links).toEqual(history._links); - done(); - }); - }); -});