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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * 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 const useAnnotations = (repository: Repository, revision: string, file: File): ApiResult<AnnotatedSource> => {
const { isLoading, error, data } = useQuery<AnnotatedSource, Error>(
export function getHistory(url: string) { repoQueryKey(repository, "annotations", revision, file.path),
return apiClient () => apiClient.get((file._links.annotate as Link).href).then((response) => response.json())
.get(url) );
.then(response => response.json())
.then(result => {
return { return {
changesets: result._embedded.changesets, isLoading,
pageCollection: { error,
_embedded: result._embedded, data,
_links: result._links,
page: result.page,
pageTotal: result.pageTotal
}
}; };
}) };
.catch(err => {
return {
error: err
};
});
}

View File

@@ -34,7 +34,7 @@ type UseChangesetsRequest = {
page?: string | number; page?: string | number;
}; };
const changesetQueryKey = (repository: NamespaceAndName, id: string) => { export const changesetQueryKey = (repository: NamespaceAndName, id: string) => {
return repoQueryKey(repository, "changeset", id); 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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import { apiClient } from "./apiclient";
import { apiClient } from "@scm-manager/ui-components"; import { useQuery } from "react-query";
import { ApiResult } from "./base";
export type ContentType = { export type ContentType = {
type : string; type: string;
language?: string; language?: string;
} };
export function getContentType(url: string) : Promise<ContentType> { function getContentType(url: string): Promise<ContentType> {
return apiClient return apiClient.head(url).then((response) => {
.head(url)
.then(response => {
return { return {
type: response.headers.get("Content-Type") || "application/octet-stream", 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 "./diff";
export * from "./notifications"; export * from "./notifications";
export * from "./configLink"; export * from "./configLink";
export * from "./history";
export * from "./contentType";
export * from "./annotations";
export { default as ApiProvider } from "./ApiProvider"; export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider"; export * from "./ApiProvider";

View File

@@ -22,46 +22,39 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC } from "react";
import { Link, Repository, File, AnnotatedSource } from "@scm-manager/ui-types"; import { File, Link, Repository } from "@scm-manager/ui-types";
import { Annotate, apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components"; import { Annotate, ErrorNotification, Loading } from "@scm-manager/ui-components";
import { getContentType } from "./contentType"; import { useAnnotations, useContentType } from "@scm-manager/ui-api";
type Props = { type Props = {
file: File; file: File;
repository: Repository; repository: Repository;
revision: string;
}; };
const AnnotateView: FC<Props> = ({ file, repository }) => { const AnnotateView: FC<Props> = ({ file, repository, revision }) => {
const [annotation, setAnnotation] = useState<AnnotatedSource | undefined>(undefined); const {
const [language, setLanguage] = useState<string | undefined>(undefined); data: annotation,
const [error, setError] = useState<Error | undefined>(undefined); isLoading: isAnnotationLoading,
const [loading, setLoading] = useState(true); error: annotationLoadError,
} = useAnnotations(repository, revision, file);
useEffect(() => { const {
const languagePromise = getContentType((file._links.self as Link).href).then(result => data: contentType,
setLanguage(result.language) isLoading: isContentTypeLoading,
); error: contentTypeLoadError,
} = useContentType((file._links.self as Link).href);
const apiClientPromise = apiClient const error = annotationLoadError || contentTypeLoadError;
.get((file._links.annotate as Link).href)
.then(response => response.json())
.then(setAnnotation);
Promise.all([languagePromise, apiClientPromise])
.then(() => setLoading(false))
.catch(setError);
}, [file]);
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
} }
if (!annotation || loading) { if (isAnnotationLoading || isContentTypeLoading || !annotation || !contentType) {
return <Loading />; return <Loading />;
} }
return <Annotate source={{ ...annotation, language }} repository={repository} />; return <Annotate source={{ ...annotation, language: contentType.language }} repository={repository} />;
}; };
export default AnnotateView; export default AnnotateView;

View File

@@ -37,7 +37,6 @@ type Props = {
file: File; file: File;
repository: Repository; repository: Repository;
revision: string; revision: string;
path: string;
breadcrumb: React.ReactNode; breadcrumb: React.ReactNode;
error?: Error; error?: Error;
}; };
@@ -78,7 +77,7 @@ const BorderLessDiv = styled.div`
export type SourceViewSelection = "source" | "annotations" | "history"; 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 [t] = useTranslation("repos");
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [selected, setSelected] = useState<SourceViewSelection>("source"); const [selected, setSelected] = useState<SourceViewSelection>("source");
@@ -215,13 +214,13 @@ const Content: FC<Props> = ({ file, repository, revision, path, breadcrumb, erro
let body; let body;
switch (selected) { switch (selected) {
case "source": case "source":
body = <SourcesView revision={revision} file={file} repository={repository} path={path} />; body = <SourcesView file={file} repository={repository} revision={revision} />;
break; break;
case "annotations": case "annotations":
body = <AnnotateView file={file} repository={repository} />; body = <AnnotateView file={file} repository={repository} revision={revision} />;
break; break;
case "history": case "history":
body = <HistoryView file={file} repository={repository} />; body = <HistoryView file={file} repository={repository} revision={revision} />;
} }
const header = showHeader(body); const header = showHeader(body);
const moreInformation = showMoreInformation(); 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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC, useState } from "react";
import { Changeset, File, PagedCollection, Repository, Link } from "@scm-manager/ui-types"; import { File, Repository } from "@scm-manager/ui-types";
import { ChangesetList, ErrorNotification, Loading, StatePaginator } from "@scm-manager/ui-components"; import { ChangesetList, ErrorNotification, Loading, StatePaginator } from "@scm-manager/ui-components";
import { getHistory } from "./history"; import { useHistory } from "@scm-manager/ui-api";
type Props = { type Props = {
file: File; file: File;
repository: Repository; repository: Repository;
revision: string;
}; };
type State = { const HistoryView: FC<Props> = ({ repository, file, revision }) => {
loaded: boolean; const [page, setPage] = useState(0);
changesets: Changeset[]; const { error, isLoading, data: history } = useHistory(repository, revision, file, { page });
page: number;
pageCollection?: PagedCollection;
error?: Error;
currentRevision: string;
};
class HistoryView extends React.Component<Props, State> { if (!history || isLoading) {
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) {
return <Loading />; return <Loading />;
} }
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
} }
const history = this.showHistory(); return (
<>
return <>{history}</>; <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; export default HistoryView;

View File

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

View File

@@ -21,16 +21,17 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react";
import React, { FC } from "react";
import SourcecodeViewer from "../components/content/SourcecodeViewer"; import SourcecodeViewer from "../components/content/SourcecodeViewer";
import ImageViewer from "../components/content/ImageViewer"; import ImageViewer from "../components/content/ImageViewer";
import DownloadViewer from "../components/content/DownloadViewer"; import DownloadViewer from "../components/content/DownloadViewer";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { getContentType } from "./contentType"; import { File, Link, Repository } from "@scm-manager/ui-types";
import { File, Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components"; import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer"; import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer";
import styled from "styled-components"; import styled from "styled-components";
import { useContentType } from "@scm-manager/ui-api";
const NoSpacingSyntaxHighlighterContainer = styled.div` const NoSpacingSyntaxHighlighterContainer = styled.div`
& pre { & pre {
@@ -43,66 +44,33 @@ type Props = {
repository: Repository; repository: Repository;
file: File; file: File;
revision: string; revision: string;
path: string;
}; };
type State = { const SourcesView: FC<Props> = ({ file, repository, revision }) => {
contentType: string; const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href);
language: string;
loaded: boolean;
error?: Error;
};
class SourcesView extends React.Component<Props, State> { if (error) {
constructor(props: Props) { return <ErrorNotification error={error} />;
super(props);
this.state = {
contentType: "",
language: "",
loaded: false,
};
} }
componentDidMount() { if (!contentTypeData || isLoading) {
const { file } = this.props; return <Loading />;
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() { let sources;
const { repository, revision } = this.props;
return `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
}
showSources() { const { type: contentType, language } = contentTypeData;
const { file, revision } = this.props; const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
const { contentType, language } = this.state;
const basePath = this.createBasePath();
if (contentType.startsWith("image/")) { if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />; sources = <ImageViewer file={file} />;
} else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) { } else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) {
return <SwitchableMarkdownViewer file={file} basePath={basePath} />; sources = <SwitchableMarkdownViewer file={file} basePath={basePath} />;
} else if (language) { } else if (language) {
return <SourcecodeViewer file={file} language={language} />; sources = <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) { } else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />; sources = <SourcecodeViewer file={file} language="none" />;
} else { } else {
return ( sources = (
<ExtensionPoint <ExtensionPoint
name="repos.sources.view" name="repos.sources.view"
props={{ props={{
@@ -116,23 +84,8 @@ class SourcesView extends React.Component<Props, State> {
</ExtensionPoint> </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>; return <NoSpacingSyntaxHighlighterContainer className="panel-block">{sources}</NoSpacingSyntaxHighlighterContainer>;
} };
}
export default SourcesView; 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();
});
});
});