implement react-query for all content views (#1708)

Implements react-query and replaces direct apiClient usage for sources, annotate and history content views.

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-06-23 14:14:56 +02:00
committed by GitHub
parent 8e47238bf7
commit d6e36e7145
12 changed files with 196 additions and 390 deletions

View File

@@ -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 => {
export const useAnnotations = (repository: Repository, revision: string, file: File): ApiResult<AnnotatedSource> => {
const { isLoading, error, data } = useQuery<AnnotatedSource, Error>(
repoQueryKey(repository, "annotations", revision, file.path),
() => apiClient.get((file._links.annotate as Link).href).then((response) => response.json())
);
return {
changesets: result._embedded.changesets,
pageCollection: {
_embedded: result._embedded,
_links: result._links,
page: result.page,
pageTotal: result.pageTotal
}
isLoading,
error,
data,
};
})
.catch(err => {
return {
error: err
};
});
}
};

View File

@@ -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);
};

View File

@@ -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;
}
};
export function getContentType(url: string) : Promise<ContentType> {
return apiClient
.head(url)
.then(response => {
function getContentType(url: string): Promise<ContentType> {
return apiClient.head(url).then((response) => {
return {
type: response.headers.get("Content-Type") || "application/octet-stream",
language: response.headers.get("X-Programming-Language") || undefined
language: response.headers.get("X-Programming-Language") || undefined,
};
})
});
}
export const useContentType = (url: string): ApiResult<ContentType> => {
const { isLoading, error, data } = useQuery<ContentType, Error>(["contentType", url], () => getContentType(url));
return {
isLoading,
error,
data,
};
};

View File

@@ -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<ChangesetCollection> => {
const queryClient = useQueryClient();
const link = (file._links.history as Link).href;
const queryParams: Record<string, string> = {};
if (request?.page) {
queryParams.page = request.page.toString();
}
return useQuery<ChangesetCollection, Error>(
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)
);
},
}
);
};

View File

@@ -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";

View File

@@ -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<Props> = ({ file, repository }) => {
const [annotation, setAnnotation] = useState<AnnotatedSource | undefined>(undefined);
const [language, setLanguage] = useState<string | undefined>(undefined);
const [error, setError] = useState<Error | undefined>(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<Props> = ({ 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 <ErrorNotification error={error} />;
}
if (!annotation || loading) {
if (isAnnotationLoading || isContentTypeLoading || !annotation || !contentType) {
return <Loading />;
}
return <Annotate source={{ ...annotation, language }} repository={repository} />;
return <Annotate source={{ ...annotation, language: contentType.language }} repository={repository} />;
};
export default AnnotateView;

View File

@@ -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<Props> = ({ file, repository, revision, path, breadcrumb, error }) => {
const Content: FC<Props> = ({ file, repository, revision, breadcrumb, error }) => {
const [t] = useTranslation("repos");
const [collapsed, setCollapsed] = useState(true);
const [selected, setSelected] = useState<SourceViewSelection>("source");
@@ -215,13 +214,13 @@ const Content: FC<Props> = ({ file, repository, revision, path, breadcrumb, erro
let body;
switch (selected) {
case "source":
body = <SourcesView revision={revision} file={file} repository={repository} path={path} />;
body = <SourcesView file={file} repository={repository} revision={revision} />;
break;
case "annotations":
body = <AnnotateView file={file} repository={repository} />;
body = <AnnotateView file={file} repository={repository} revision={revision} />;
break;
case "history":
body = <HistoryView file={file} repository={repository} />;
body = <HistoryView file={file} repository={repository} revision={revision} />;
}
const header = showHeader(body);
const moreInformation = showMoreInformation();

View File

@@ -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<Props> = ({ repository, file, revision }) => {
const [page, setPage] = useState(0);
const { error, isLoading, data: history } = useHistory(repository, revision, file, { page });
class HistoryView extends React.Component<Props, State> {
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 (
<>
<div className="panel-block">
<ChangesetList repository={repository} changesets={changesets} />
</div>
<div className="panel-footer">
<StatePaginator
page={currentPage}
collection={pageCollection}
updatePage={(newPage: number) => this.updatePage(newPage)}
/>
</div>
</>
);
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
if (!history || isLoading) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const history = this.showHistory();
return <>{history}</>;
}
}
return (
<>
<div className="panel-block">
<ChangesetList repository={repository} changesets={history._embedded.changesets} />
</div>
<div className="panel-footer">
<StatePaginator page={page + 1} collection={history} updatePage={(newPage: number) => setPage(newPage - 1)} />
</div>
</>
);
};
export default HistoryView;

View File

@@ -176,7 +176,6 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
file={file}
repository={repository}
revision={revision || file.revision}
path={path}
breadcrumb={renderBreadcrumb()}
error={error}
/>

View File

@@ -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,66 +44,33 @@ type Props = {
repository: Repository;
file: File;
revision: string;
path: string;
};
type State = {
contentType: string;
language: string;
loaded: boolean;
error?: Error;
};
const SourcesView: FC<Props> = ({ file, repository, revision }) => {
const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href);
class SourcesView extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
contentType: "",
language: "",
loaded: false,
};
if (error) {
return <ErrorNotification error={error} />;
}
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,
});
});
if (!contentTypeData || isLoading) {
return <Loading />;
}
createBasePath() {
const { repository, revision } = this.props;
return `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
}
let sources;
showSources() {
const { file, revision } = this.props;
const { contentType, language } = this.state;
const basePath = this.createBasePath();
const { type: contentType, language } = contentTypeData;
const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
sources = <ImageViewer file={file} />;
} else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) {
return <SwitchableMarkdownViewer file={file} basePath={basePath} />;
sources = <SwitchableMarkdownViewer file={file} basePath={basePath} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;
sources = <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />;
sources = <SourcecodeViewer file={file} language="none" />;
} else {
return (
sources = (
<ExtensionPoint
name="repos.sources.view"
props={{
@@ -116,23 +84,8 @@ class SourcesView extends React.Component<Props, State> {
</ExtensionPoint>
);
}
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const sources = this.showSources();
return <NoSpacingSyntaxHighlighterContainer className="panel-block">{sources}</NoSpacingSyntaxHighlighterContainer>;
}
}
};
export default SourcesView;

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});