Introduce stale while revalidate pattern (#1555)

This Improves the frontend performance with stale while
revalidate pattern.

There are noticeable performance problems in the frontend that
needed addressing. While implementing the stale-while-revalidate
pattern to display cached responses while re-fetching up-to-date
data in the background, in the same vein we used the opportunity
to remove legacy code involving redux as much as possible,
cleaned up many components and converted them to functional
react components.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-02-24 08:17:40 +01:00
committed by GitHub
parent ad5c8102c0
commit 3a8d031ed5
243 changed files with 150259 additions and 80227 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: improve frontend performance with stale while revalidate pattern ([#1555](https://github.com/scm-manager/scm-manager/pull/1555))

View File

@@ -16,7 +16,7 @@
"devDependencies": {
"@scm-manager/babel-preset": "^2.11.1",
"@scm-manager/eslint-config": "^2.11.1",
"@scm-manager/jest-preset": "^2.12.3",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/plugin-scripts": "^1.0.1",
"@scm-manager/prettier-config": "^2.11.1"
},

View File

@@ -15,7 +15,7 @@
"devDependencies": {
"@scm-manager/babel-preset": "^2.11.1",
"@scm-manager/eslint-config": "^2.11.1",
"@scm-manager/jest-preset": "^2.12.3",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/plugin-scripts": "^1.0.1",
"@scm-manager/prettier-config": "^2.11.1"
},

View File

@@ -15,7 +15,7 @@
"devDependencies": {
"@scm-manager/babel-preset": "^2.11.1",
"@scm-manager/eslint-config": "^2.11.1",
"@scm-manager/jest-preset": "^2.12.3",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/plugin-scripts": "^1.0.1",
"@scm-manager/prettier-config": "^2.11.1"
},

View File

@@ -15,7 +15,7 @@
"devDependencies": {
"@scm-manager/babel-preset": "^2.11.1",
"@scm-manager/eslint-config": "^2.11.1",
"@scm-manager/jest-preset": "^2.12.3",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/plugin-scripts": "^1.0.1",
"@scm-manager/prettier-config": "^2.11.1"
},

View File

@@ -0,0 +1,43 @@
{
"name": "@scm-manager/ui-api",
"version": "2.13.1-SNAPSHOT",
"description": "React hook api for the SCM-Manager backend",
"main": "src/index.ts",
"files": [
"dist",
"src"
],
"repository": "https://github.com/scm-manager/scm-manager",
"author": "SCM Team <scm-team@cloudogu.com>",
"license": "MIT",
"scripts": {
"test": "jest src/",
"typecheck": "tsc"
},
"devDependencies": {
"@scm-manager/babel-preset": "^2.11.2",
"@scm-manager/eslint-config": "^2.10.1",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@testing-library/react-hooks": "^5.0.3",
"react-test-renderer": "^17.0.1"
},
"dependencies": {
"@scm-manager/ui-types": "^2.13.1-SNAPSHOT",
"fetch-mock-jest": "^1.5.1",
"react": "^16.8.6",
"react-query": "^3.5.16",
"query-string": "5"
},
"babel": {
"presets": [
"@scm-manager/babel-preset"
]
},
"jest": {
"preset": "@scm-manager/jest-preset"
},
"prettier": "@scm-manager/prettier-config",
"private": true
}

View File

@@ -0,0 +1,60 @@
/*
* 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 { LegacyContext, useLegacyContext } from "./LegacyContext";
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 => {
return ({ children }) => <ApiProvider {...context}>{children}</ApiProvider>;
};
it("should register QueryClient", () => {
const { result } = renderHook(() => useQueryClient(), {
wrapper: createWrapper()
});
expect(result.current).toBeDefined();
});
it("should pass legacy context QueryClient", () => {
let msg: string;
const onIndexFetched = () => {
msg = "hello";
};
const { result } = renderHook(() => useLegacyContext(), {
wrapper: createWrapper({ onIndexFetched })
});
if (result.current?.onIndexFetched) {
result.current.onIndexFetched({ version: "a.b.c", _links: {} });
}
expect(msg!).toEqual("hello");
});
});

View File

@@ -0,0 +1,81 @@
/*
* 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, useEffect } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import { LegacyContext, LegacyContextProvider } from "./LegacyContext";
import { IndexResources, Me } from "@scm-manager/ui-types";
import { reset } from "./reset";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// refetch on focus can reset form inputs
refetchOnWindowFocus: false
}
}
});
type Props = LegacyContext & {
index?: IndexResources;
me?: Me;
};
const ApiProvider: FC<Props> = ({ children, index, me, onMeFetched, onIndexFetched }) => {
useEffect(() => {
if (index) {
queryClient.setQueryData("index", index);
if (onIndexFetched) {
onIndexFetched(index);
}
}
}, [index, onIndexFetched]);
useEffect(() => {
if (me) {
queryClient.setQueryData("me", me);
if (onMeFetched) {
onMeFetched(me);
}
}
}, [me, onMeFetched]);
return (
<QueryClientProvider client={queryClient}>
<LegacyContextProvider onIndexFetched={onIndexFetched} onMeFetched={onMeFetched}>
{children}
</LegacyContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
export { Props as ApiProviderProps };
export const clearCache = () => {
// we do a safe reset instead of clearing the whole cache
// this should avoid missing link errors for index
return reset(queryClient);
};
export default ApiProvider;

View File

@@ -0,0 +1,46 @@
/*
* 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 { LegacyContext, LegacyContextProvider, useLegacyContext } from "./LegacyContext";
import { FC } from "react";
import * as React from "react";
import { renderHook } from "@testing-library/react-hooks";
describe("LegacyContext tests", () => {
const createWrapper = (context?: LegacyContext): FC => {
return ({ children }) => <LegacyContextProvider {...context}>{children}</LegacyContextProvider>;
};
it("should return provided context", () => {
const { result } = renderHook(() => useLegacyContext(), {
wrapper: createWrapper()
});
expect(result.current).toBeDefined();
});
it("should fail without providers", () => {
const { result } = renderHook(() => useLegacyContext());
expect(result.error).toBeDefined();
});
});

View File

@@ -22,32 +22,24 @@
* SOFTWARE.
*/
import { apiClient } from "@scm-manager/ui-components";
import { IndexResources, Me } from "@scm-manager/ui-types";
import React, { createContext, FC, useContext } from "react";
const waitForRestart = () => {
const endTime = Number(new Date()) + 60000;
let started = false;
const executor = (resolve, reject) => {
// we need some initial delay
if (!started) {
started = true;
setTimeout(executor, 1000, resolve, reject);
} else {
apiClient
.get("")
.then(resolve)
.catch(() => {
if (Number(new Date()) < endTime) {
setTimeout(executor, 500, resolve, reject);
} else {
reject(new Error("timeout reached"));
}
});
}
};
return new Promise<void>(executor);
export type LegacyContext = {
onIndexFetched?: (index: IndexResources) => void;
onMeFetched?: (me: Me) => void;
};
export default waitForRestart;
const Context = createContext<LegacyContext | undefined>(undefined);
export const useLegacyContext = () => {
const context = useContext(Context);
if (!context) {
throw new Error("useLegacyContext can't be used outside of ApiProvider");
}
return context;
};
export const LegacyContextProvider: FC<LegacyContext> = ({ onIndexFetched, onMeFetched, children }) => (
<Context.Provider value={{ onIndexFetched, onMeFetched }}>{children}</Context.Provider>
);

View File

@@ -0,0 +1,55 @@
/*
* 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-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { useUpdateInfo } from "./admin";
import { UpdateInfo } from "@scm-manager/ui-types";
import { setIndexLink } from "./tests/indexLinks";
describe("Test admin hooks", () => {
describe("useUpdateInfo tests", () => {
it("should get update info", async () => {
const updateInfo: UpdateInfo = {
latestVersion: "x.y.z",
link: "http://heartofgold@hitchhiker.com/x.y.z"
};
fetchMock.getOnce("/api/v2/updateInfo", updateInfo);
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "updateInfo", "/updateInfo");
const { result, waitFor } = renderHook(() => useUpdateInfo(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data).toEqual(updateInfo);
});
});
});

View File

@@ -22,25 +22,14 @@
* SOFTWARE.
*/
import { ApiResult, useRequiredIndexLink } from "./base";
import { UpdateInfo } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "@scm-manager/ui-components";
import { CONTENT_TYPE_USER } from "../modules/users";
export function convertToInternal(url: string, newPassword: string) {
return apiClient
.put(
url,
{
newPassword
},
CONTENT_TYPE_USER
)
.then(response => {
return response;
});
}
export function convertToExternal(url: string) {
return apiClient.put(url, {}, CONTENT_TYPE_USER).then(response => {
return response;
});
}
export const useUpdateInfo = (): ApiResult<UpdateInfo | null> => {
const indexLink = useRequiredIndexLink("updateInfo");
return useQuery<UpdateInfo | null, Error>("updateInfo", () =>
apiClient.get(indexLink).then(response => (response.status === 204 ? null : response.json()))
);
};

View File

@@ -23,14 +23,7 @@
*/
import { contextPath } from "./urls";
import {
createBackendError,
ForbiddenError,
isBackendError,
UnauthorizedError,
BackendErrorContent,
TOKEN_EXPIRED_ERROR_CODE
} from "./errors";
import { BackendErrorContent, createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors";
type SubscriptionEvent = {
type: string;
@@ -157,11 +150,14 @@ export function createUrlWithIdentifiers(url: string): string {
type ErrorListener = (error: Error) => void;
type RequestListener = (url: string, options?: RequestInit) => void;
class ApiClient {
errorListeners: ErrorListener[] = [];
requestListeners: RequestListener[] = [];
get = (url: string): Promise<Response> => {
return fetch(createUrl(url), applyFetchOptions({}))
return this.request(url, applyFetchOptions({}))
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
@@ -204,7 +200,7 @@ class ApiClient {
method: "HEAD"
};
options = applyFetchOptions(options);
return fetch(createUrl(url), options)
return this.request(url, options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
@@ -214,7 +210,7 @@ class ApiClient {
method: "DELETE"
};
options = applyFetchOptions(options);
return fetch(createUrl(url), options)
return this.request(url, options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
@@ -260,7 +256,7 @@ class ApiClient {
options.headers["Content-Type"] = contentType;
}
return fetch(createUrl(url), options)
return this.request(url, options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
@@ -294,10 +290,23 @@ class ApiClient {
return () => es.close();
}
onRequest = (requestListener: RequestListener) => {
this.requestListeners.push(requestListener);
};
onError = (errorListener: ErrorListener) => {
this.errorListeners.push(errorListener);
};
private request = (url: string, options: RequestInit) => {
this.notifyRequestListeners(url, options);
return fetch(createUrl(url), options);
};
private notifyRequestListeners = (url: string, options: RequestInit) => {
this.requestListeners.forEach(requestListener => requestListener(url, options));
};
private notifyAndRethrow = (error: Error): never => {
this.errorListeners.forEach(errorListener => errorListener(error));
throw error;

View File

@@ -0,0 +1,237 @@
/*
* 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-jest";
import { useIndex, useIndexJsonResource, useIndexLink, useIndexLinks, useRequiredIndexLink, useVersion } from "./base";
import { renderHook } from "@testing-library/react-hooks";
import { LegacyContext } from "./LegacyContext";
import { IndexResources, Link } from "@scm-manager/ui-types";
import createWrapper from "./tests/createWrapper";
import { QueryClient } from "react-query";
describe("Test base api hooks", () => {
describe("useIndex tests", () => {
fetchMock.get("/api/v2/", {
version: "x.y.z",
_links: {}
});
it("should return index", async () => {
const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper() });
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?.version).toEqual("x.y.z");
});
it("should call onIndexFetched of LegacyContext", async () => {
let index: IndexResources;
const context: LegacyContext = {
onIndexFetched: fetchedIndex => {
index = fetchedIndex;
}
};
const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper(context) });
await waitFor(() => {
return !!result.current.data;
});
expect(index!.version).toEqual("x.y.z");
});
});
describe("useIndexLink tests", () => {
it("should throw an error if index is not available", () => {
const { result } = renderHook(() => useIndexLink("spaceships"), { wrapper: createWrapper() });
expect(result.error).toBeDefined();
});
it("should return undefined for unknown link", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {}
});
const { result } = renderHook(() => useIndexLink("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current).toBeUndefined();
});
it("should return undefined for link array", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
spaceships: [
{
name: "heartOfGold",
href: "/spaceships/heartOfGold"
},
{
name: "razorCrest",
href: "/spaceships/razorCrest"
}
]
}
});
const { result } = renderHook(() => useIndexLink("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current).toBeUndefined();
});
it("should return link", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
spaceships: {
href: "/api/spaceships"
}
}
});
const { result } = renderHook(() => useIndexLink("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current).toBe("/api/spaceships");
});
});
describe("useIndexLinks tests", () => {
it("should throw an error if index is not available", async () => {
const { result } = renderHook(() => useIndexLinks(), { wrapper: createWrapper() });
expect(result.error).toBeDefined();
});
it("should return links", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
spaceships: {
href: "/api/spaceships"
}
}
});
const { result } = renderHook(() => useIndexLinks(), {
wrapper: createWrapper(undefined, queryClient)
});
expect((result.current!.spaceships as Link).href).toBe("/api/spaceships");
});
});
describe("useVersion tests", () => {
it("should throw an error if version is not available", async () => {
const { result } = renderHook(() => useVersion(), { wrapper: createWrapper() });
expect(result.error).toBeDefined();
});
it("should return version", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z"
});
const { result } = renderHook(() => useVersion(), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current).toBe("x.y.z");
});
});
describe("useRequiredIndexLink tests", () => {
it("should throw error for undefined link", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {}
});
const { result } = renderHook(() => useRequiredIndexLink("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.error).toBeDefined();
});
it("should return link", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
spaceships: {
href: "/api/spaceships"
}
}
});
const { result } = renderHook(() => useRequiredIndexLink("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current).toBe("/api/spaceships");
});
});
describe("useIndexJsonResource tests", () => {
it("should return json resource from link", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
spaceships: {
href: "/spaceships"
}
}
});
const spaceship = {
name: "heartOfGold"
};
fetchMock.get("/api/v2/spaceships", spaceship);
const { result, waitFor } = renderHook(() => useIndexJsonResource<typeof spaceship>("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data!.name).toBe("heartOfGold");
});
});
it("should return nothing if link is not available", () => {
const queryClient = new QueryClient();
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {}
});
const { result } = renderHook(() => useIndexJsonResource<{}>("spaceships"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeFalsy();
expect(result.current.data).toBeFalsy();
});
});

100
scm-ui/ui-api/src/base.ts Normal file
View File

@@ -0,0 +1,100 @@
/*
* 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 { IndexResources, Link } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { useLegacyContext } from "./LegacyContext";
import { MissingLinkError, UnauthorizedError } from "./errors";
export type ApiResult<T> = {
isLoading: boolean;
error: Error | null;
data?: T;
};
export const useIndex = (): ApiResult<IndexResources> => {
const legacy = useLegacyContext();
return useQuery<IndexResources, Error>("index", () => apiClient.get("/").then(response => response.json()), {
onSuccess: index => {
// ensure legacy code is notified
if (legacy.onIndexFetched) {
legacy.onIndexFetched(index);
}
},
refetchOnMount: false,
retry: (failureCount, error) => {
// The index resource returns a 401 if the access token expired.
// This only happens once because the error response automatically invalidates the cookie.
// In this event, we have to try the request once again.
return error instanceof UnauthorizedError && failureCount === 0;
}
});
};
export const useIndexLink = (name: string): string | undefined => {
const { data } = useIndex();
if (!data) {
throw new Error("could not find index data");
}
const linkObject = data._links[name] as Link;
if (linkObject && linkObject.href) {
return linkObject.href;
}
};
export const useIndexLinks = () => {
const { data } = useIndex();
if (!data) {
throw new Error("could not find index data");
}
return data._links;
};
export const useRequiredIndexLink = (name: string): string => {
const link = useIndexLink(name);
if (!link) {
throw new MissingLinkError(`Could not find link ${name} in index resource`);
}
return link;
};
export const useVersion = (): string => {
const { data } = useIndex();
if (!data) {
throw new Error("could not find index data");
}
const { version } = data;
if (!version) {
throw new Error("could not find version in index data");
}
return version;
};
export const useIndexJsonResource = <T>(name: string): ApiResult<T> => {
const link = useIndexLink(name);
return useQuery<T, Error>(name, () => apiClient.get(link!).then(response => response.json()), {
enabled: !!link
});
};

View File

@@ -0,0 +1,219 @@
/*
* 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 { Branch, BranchCollection, Repository } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { useBranch, useBranches, useCreateBranch, useDeleteBranch } from "./branches";
import { act } from "react-test-renderer";
describe("Test branches hooks", () => {
const repository: Repository = {
namespace: "hitchhiker",
name: "heart-of-gold",
type: "hg",
_links: {
branches: {
href: "/hog/branches"
}
}
};
const develop: Branch = {
name: "develop",
revision: "42",
_links: {
delete: {
href: "/hog/branches/develop"
}
}
};
const branches: BranchCollection = {
_embedded: {
branches: [develop]
},
_links: {}
};
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
});
afterEach(() => {
fetchMock.reset();
});
describe("useBranches tests", () => {
const fetchBrances = async () => {
fetchMock.getOnce("/api/v2/hog/branches", branches);
const { result, waitFor } = renderHook(() => useBranches(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
return result.current.data;
};
it("should return branches", async () => {
const branches = await fetchBrances();
expect(branches).toEqual(branches);
});
it("should add branches to cache", async () => {
await fetchBrances();
const data = queryClient.getQueryData<BranchCollection>([
"repository",
"hitchhiker",
"heart-of-gold",
"branches"
]);
expect(data).toEqual(branches);
});
});
describe("useBranch tests", () => {
const fetchBranch = async () => {
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitFor } = renderHook(() => useBranch(repository, "develop"), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.error).toBeUndefined();
await waitFor(() => {
return !!result.current.data;
});
return result.current.data;
};
it("should return branch", async () => {
const branch = await fetchBranch();
expect(branch).toEqual(develop);
});
});
describe("useCreateBranch tests", () => {
const createBranch = async () => {
fetchMock.postOnce("/api/v2/hog/branches", {
status: 201,
headers: {
Location: "/hog/branches/develop"
}
});
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitForNextUpdate } = renderHook(() => useCreateBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create({ name: "develop", parent: "main" });
return waitForNextUpdate();
});
return result.current;
};
it("should create branch", async () => {
const { branch } = await createBranch();
expect(branch).toEqual(develop);
});
it("should cache created branch", async () => {
await createBranch();
const branch = queryClient.getQueryData<Branch>([
"repository",
"hitchhiker",
"heart-of-gold",
"branch",
"develop"
]);
expect(branch).toEqual(develop);
});
it("should invalidate cached branches list", async () => {
queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branches"], branches);
await createBranch();
const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branches"]);
expect(queryState!.isInvalidated).toBe(true);
});
});
describe("useDeleteBranch tests", () => {
const deleteBranch = async () => {
fetchMock.deleteOnce("/api/v2/hog/branches/develop", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(develop);
return waitForNextUpdate();
});
return result.current;
};
it("should delete branch", async () => {
const { isDeleted } = await deleteBranch();
expect(isDeleted).toBe(true);
});
it("should invalidate branch", async () => {
queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"], develop);
await deleteBranch();
const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"]);
expect(queryState!.isInvalidated).toBe(true);
});
it("should invalidate cached branches list", async () => {
queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branches"], branches);
await deleteBranch();
const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branches"]);
expect(queryState!.isInvalidated).toBe(true);
});
});
});

View File

@@ -0,0 +1,104 @@
/*
* 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 { Branch, BranchCollection, BranchCreation, Link, Repository } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { ApiResult } from "./base";
import { branchQueryKey, repoQueryKey } from "./keys";
import { apiClient } from "./apiclient";
import { concat } from "./urls";
export const useBranches = (repository: Repository): ApiResult<BranchCollection> => {
const link = requiredLink(repository, "branches");
return useQuery<BranchCollection, Error>(
repoQueryKey(repository, "branches"),
() => apiClient.get(link).then(response => response.json())
// we do not populate the cache for a single branch,
// because we have no pagination for branches and if we have a lot of them
// the population slows us down
);
};
export const useBranch = (repository: Repository, name: string): ApiResult<Branch> => {
const link = requiredLink(repository, "branches");
return useQuery<Branch, Error>(branchQueryKey(repository, name), () =>
apiClient.get(concat(link, name)).then(response => response.json())
);
};
const createBranch = (link: string) => {
return (branch: BranchCreation) => {
return apiClient
.post(link, branch, "application/vnd.scmm-branchRequest+json;v=2")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateBranch = (repository: Repository) => {
const queryClient = useQueryClient();
const link = requiredLink(repository, "branches");
const { mutate, isLoading, error, data } = useMutation<Branch, Error, BranchCreation>(createBranch(link), {
onSuccess: async branch => {
queryClient.setQueryData(branchQueryKey(repository, branch), branch);
await queryClient.invalidateQueries(repoQueryKey(repository, "branches"));
}
});
return {
create: (branch: BranchCreation) => mutate(branch),
isLoading,
error,
branch: data
};
};
export const useDeleteBranch = (repository: Repository) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Branch>(
branch => {
const deleteUrl = (branch._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, branch) => {
await queryClient.invalidateQueries(branchQueryKey(repository, branch));
await queryClient.invalidateQueries(repoQueryKey(repository, "branches"));
}
}
);
return {
remove: (branch: Branch) => mutate(branch),
isLoading,
error,
isDeleted: !!data
};
};

View File

@@ -0,0 +1,179 @@
/*
* 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 { Branch, Changeset, ChangesetCollection, Repository } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { useChangeset, useChangesets } from "./changesets";
describe("Test changeset hooks", () => {
const repository: Repository = {
namespace: "hitchhiker",
name: "heart-of-gold",
type: "hg",
_links: {
changesets: {
href: "/r/c"
}
}
};
const develop: Branch = {
name: "develop",
revision: "42",
_links: {
history: {
href: "/r/b/c"
}
}
};
const changeset: Changeset = {
id: "42",
description: "Awesome change",
date: new Date(),
author: {
name: "Arthur Dent"
},
_embedded: {},
_links: {}
};
const changesets: ChangesetCollection = {
page: 1,
pageTotal: 1,
_embedded: {
changesets: [changeset]
},
_links: {}
};
const expectChangesetCollection = (result?: ChangesetCollection) => {
expect(result?._embedded.changesets[0].id).toBe(changesets._embedded.changesets[0].id);
};
afterEach(() => {
fetchMock.reset();
});
describe("useChangesets tests", () => {
it("should return changesets", async () => {
fetchMock.getOnce("/api/v2/r/c", changesets);
const queryClient = createInfiniteCachingClient();
const { result, waitFor } = renderHook(() => useChangesets(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expectChangesetCollection(result.current.data);
});
it("should return changesets for page", async () => {
fetchMock.getOnce("/api/v2/r/c", changesets, {
query: {
page: 42
}
});
const queryClient = createInfiniteCachingClient();
const { result, waitFor } = renderHook(() => useChangesets(repository, { page: 42 }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expectChangesetCollection(result.current.data);
});
it("should use link from branch", async () => {
fetchMock.getOnce("/api/v2/r/b/c", changesets);
const queryClient = createInfiniteCachingClient();
const { result, waitFor } = renderHook(() => useChangesets(repository, { branch: develop }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expectChangesetCollection(result.current.data);
});
it("should populate changeset cache", async () => {
fetchMock.getOnce("/api/v2/r/c", changesets);
const queryClient = createInfiniteCachingClient();
const { result, waitFor } = renderHook(() => useChangesets(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
const changeset: Changeset | undefined = queryClient.getQueryData([
"repository",
"hitchhiker",
"heart-of-gold",
"changeset",
"42"
]);
expect(changeset?.id).toBe("42");
});
});
describe("useChangeset tests", () => {
it("should return changes", async () => {
fetchMock.get("/api/v2/r/c/42", changeset);
const queryClient = createInfiniteCachingClient();
const { result, waitFor } = renderHook(() => useChangeset(repository, "42"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
const c = result.current.data;
expect(c?.description).toBe("Awesome change");
});
});
});

View File

@@ -0,0 +1,77 @@
/*
* 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 { Branch, Changeset, ChangesetCollection, NamespaceAndName, Repository } from "@scm-manager/ui-types";
import { useQuery, useQueryClient } from "react-query";
import { requiredLink } from "./links";
import { apiClient } from "./apiclient";
import { ApiResult } from "./base";
import { branchQueryKey, repoQueryKey } from "./keys";
import { concat } from "./urls";
type UseChangesetsRequest = {
branch?: Branch;
page?: string | number;
};
const changesetQueryKey = (repository: NamespaceAndName, id: string) => {
return repoQueryKey(repository, "changeset", id);
};
export const useChangesets = (
repository: Repository,
request?: UseChangesetsRequest
): ApiResult<ChangesetCollection> => {
const queryClient = useQueryClient();
let link: string;
let branch = "_";
if (request?.branch) {
link = requiredLink(request.branch, "history");
branch = request.branch.name;
} else {
link = requiredLink(repository, "changesets");
}
if (request?.page) {
link = `${link}?page=${request.page}`;
}
const key = branchQueryKey(repository, branch, "changesets", request?.page || 0);
return useQuery<ChangesetCollection, Error>(key, () => apiClient.get(link).then(response => response.json()), {
onSuccess: changesetCollection => {
changesetCollection._embedded.changesets.forEach(changeset => {
queryClient.setQueryData(changesetQueryKey(repository, changeset.id), changeset);
});
}
});
};
export const useChangeset = (repository: Repository, id: string): ApiResult<Changeset> => {
const changesetsLink = requiredLink(repository, "changesets");
return useQuery<Changeset, Error>(changesetQueryKey(repository, id), () =>
apiClient.get(concat(changesetsLink, id)).then(response => response.json())
);
};

View File

@@ -0,0 +1,113 @@
/*
* 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 { Config } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { useConfig, useUpdateConfig } from "./config";
import { act } from "react-test-renderer";
describe("Test config hooks", () => {
const config: Config = {
anonymousAccessEnabled: false,
anonymousMode: "OFF",
baseUrl: "",
dateFormat: "",
disableGroupingGrid: false,
enableProxy: false,
enabledUserConverter: false,
enabledXsrfProtection: false,
forceBaseUrl: false,
loginAttemptLimit: 0,
loginAttemptLimitTimeout: 0,
loginInfoUrl: "",
mailDomainName: "",
namespaceStrategy: "",
pluginUrl: "",
proxyExcludes: [],
proxyPassword: null,
proxyPort: 0,
proxyServer: "",
proxyUser: null,
realmDescription: "",
releaseFeedUrl: "",
skipFailedAuthenticators: false,
_links: {
update: {
href: "/config"
}
}
};
afterEach(() => {
fetchMock.reset();
});
describe("useConfig tests", () => {
it("should return config", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "config", "/config");
fetchMock.get("/api/v2/config", config);
const { result, waitFor } = renderHook(() => useConfig(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(config);
});
});
describe("useUpdateConfig tests", () => {
it("should update config", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "config", "/config");
const newConfig = {
...config,
baseUrl: "/hog"
};
fetchMock.putOnce("/api/v2/config", {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useUpdateConfig(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(newConfig);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isUpdated).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["config"])).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,56 @@
/*
* 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, useIndexLink } from "./base";
import { Config } from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "@scm-manager/ui-components";
import { requiredLink } from "./links";
export const useConfig = (): ApiResult<Config> => {
const indexLink = useIndexLink("config");
return useQuery<Config, Error>("config", () => apiClient.get(indexLink!).then(response => response.json()), {
enabled: !!indexLink
});
};
export const useUpdateConfig = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data, reset } = useMutation<unknown, Error, Config>(
config => {
const updateUrl = requiredLink(config, "update");
return apiClient.put(updateUrl, config, "application/vnd.scmm-config+json;v=2");
},
{
onSuccess: () => queryClient.invalidateQueries("config")
}
);
return {
update: (config: Config) => mutate(config),
isLoading,
error,
isUpdated: !!data,
reset
};
};

View File

@@ -26,11 +26,13 @@ type Context = {
type: string;
id: string;
}[];
export type Violation = {
path?: string;
message: string;
key?: string;
};
export type AdditionalMessage = {
key?: string;
message?: string;

View File

@@ -0,0 +1,241 @@
/*
* 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 { Group } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import { renderHook } from "@testing-library/react-hooks";
import { useCreateGroup, useDeleteGroup, useGroup, useGroups, useUpdateGroup } from "./groups";
import createWrapper from "./tests/createWrapper";
import { act } from "react-test-renderer";
describe("Test group hooks", () => {
const jedis: Group = {
name: "jedis",
description: "May the force be with you",
external: false,
members: [],
type: "xml",
_links: {
delete: {
href: "/groups/jedis"
},
update: {
href: "/groups/jedis"
}
},
_embedded: {
members: []
}
};
const jedisCollection = {
_embedded: {
groups: [jedis]
}
};
afterEach(() => {
fetchMock.reset();
});
describe("useGroups tests", () => {
it("should return groups", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.get("/api/v2/groups", jedisCollection);
const { result, waitFor } = renderHook(() => useGroups(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(jedisCollection);
});
it("should return paged groups", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.get("/api/v2/groups", jedisCollection, {
query: {
page: "42"
}
});
const { result, waitFor } = renderHook(() => useGroups({ page: 42 }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(jedisCollection);
});
it("should return searched groups", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.get("/api/v2/groups", jedisCollection, {
query: {
q: "jedis"
}
});
const { result, waitFor } = renderHook(() => useGroups({ search: "jedis" }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(jedisCollection);
});
it("should update group cache", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.get("/api/v2/groups", jedisCollection);
const { result, waitFor } = renderHook(() => useGroups(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(queryClient.getQueryData(["group", "jedis"])).toEqual(jedis);
});
});
describe("useGroup tests", () => {
it("should return group", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.get("/api/v2/groups/jedis", jedis);
const { result, waitFor } = renderHook(() => useGroup("jedis"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(jedis);
});
});
describe("useCreateGroup tests", () => {
it("should create group", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.postOnce("/api/v2/groups", {
status: 201,
headers: {
Location: "/groups/jedis"
}
});
fetchMock.getOnce("/api/v2/groups/jedis", jedis);
const { result, waitForNextUpdate } = renderHook(() => useCreateGroup(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(jedis);
return waitForNextUpdate();
});
expect(result.current.group).toEqual(jedis);
});
it("should fail without location header", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.postOnce("/api/v2/groups", {
status: 201
});
fetchMock.getOnce("/api/v2/groups/jedis", jedis);
const { result, waitForNextUpdate } = renderHook(() => useCreateGroup(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(jedis);
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
});
describe("useDeleteGroup tests", () => {
it("should delete group", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
fetchMock.deleteOnce("/api/v2/groups/jedis", {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteGroup(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(jedis);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isDeleted).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["group", "jedis"])).toBeUndefined();
});
});
describe("useUpdateGroup tests", () => {
it("should update group", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "groups", "/groups");
const newJedis = {
...jedis,
description: "may the 4th be with you"
};
fetchMock.putOnce("/api/v2/groups/jedis", {
status: 200
});
fetchMock.getOnce("/api/v2/groups/jedis", newJedis);
const { result, waitForNextUpdate } = renderHook(() => useUpdateGroup(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(newJedis);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isUpdated).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["group", "jedis"])).toBeUndefined();
expect(queryClient.getQueryData(["groups"])).toBeUndefined();
});
});
});

141
scm-ui/ui-api/src/groups.ts Normal file
View File

@@ -0,0 +1,141 @@
/*
* 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, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Group, GroupCollection, GroupCreation, Link } from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { concat } from "./urls";
export type UseGroupsRequest = {
page?: number | string;
search?: string;
};
export const useGroups = (request?: UseGroupsRequest): ApiResult<GroupCollection> => {
const queryClient = useQueryClient();
const indexLink = useRequiredIndexLink("groups");
const queryParams: Record<string, string> = {};
if (request?.search) {
queryParams.q = request.search;
}
if (request?.page) {
queryParams.page = request.page.toString();
}
return useQuery<GroupCollection, Error>(
["groups", request?.search || "", request?.page || 0],
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()),
{
onSuccess: (groups: GroupCollection) => {
groups._embedded.groups.forEach((group: Group) => queryClient.setQueryData(["group", group.name], group));
}
}
);
};
export const useGroup = (name: string): ApiResult<Group> => {
const indexLink = useRequiredIndexLink("groups");
return useQuery<Group, Error>(["group", name], () =>
apiClient.get(concat(indexLink, name)).then(response => response.json())
);
};
const createGroup = (link: string) => {
return (group: GroupCreation) => {
return apiClient
.post(link, group, "application/vnd.scmm-group+json;v=2")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateGroup = () => {
const queryClient = useQueryClient();
const link = useRequiredIndexLink("groups");
const { mutate, data, isLoading, error } = useMutation<Group, Error, GroupCreation>(createGroup(link), {
onSuccess: group => {
queryClient.setQueryData(["group", group.name], group);
return queryClient.invalidateQueries(["groups"]);
}
});
return {
create: (group: GroupCreation) => mutate(group),
isLoading,
error,
group: data
};
};
export const useUpdateGroup = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Group>(
group => {
const updateUrl = (group._links.update as Link).href;
return apiClient.put(updateUrl, group, "application/vnd.scmm-group+json;v=2");
},
{
onSuccess: async (_, group) => {
await queryClient.invalidateQueries(["group", group.name]);
await queryClient.invalidateQueries(["groups"]);
}
}
);
return {
update: (group: Group) => mutate(group),
isLoading,
error,
isUpdated: !!data
};
};
export const useDeleteGroup = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Group>(
group => {
const deleteUrl = (group._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, name) => {
await queryClient.invalidateQueries(["group", name]);
await queryClient.invalidateQueries(["groups"]);
}
}
);
return {
remove: (group: Group) => mutate(group),
isLoading,
error,
isDeleted: !!data
};
};

View File

@@ -0,0 +1,48 @@
/*
* 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 * as urls from "./urls";
export { urls };
export * from "./errors";
export * from "./apiclient";
export * from "./base";
export * from "./login";
export * from "./groups";
export * from "./users";
export * from "./repositories";
export * from "./namespaces";
export * from "./branches";
export * from "./changesets";
export * from "./tags";
export * from "./config";
export * from "./admin";
export * from "./plugins";
export * from "./repository-roles";
export * from "./permissions";
export * from "./sources";
export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";

50
scm-ui/ui-api/src/keys.ts Normal file
View File

@@ -0,0 +1,50 @@
/*
* 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 { Branch, NamespaceAndName } from "@scm-manager/ui-types";
export const repoQueryKey = (repository: NamespaceAndName, ...values: unknown[]) => {
return ["repository", repository.namespace, repository.name, ...values];
};
const isBranch = (branch: string | Branch): branch is Branch => {
return (branch as Branch).name !== undefined;
};
export const branchQueryKey = (
repository: NamespaceAndName,
branch: string | Branch | undefined,
...values: unknown[]
) => {
let branchName;
if (!branch) {
branchName = "_";
} else if (isBranch(branch)) {
branchName = branch.name;
} else {
branchName = branch;
}
return [...repoQueryKey(repository), "branch", branchName, ...values];
};

View File

@@ -0,0 +1,65 @@
/*
* 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 { requiredLink } from "./links";
describe("requireLink tests", () => {
it("should return required link", () => {
const link = requiredLink(
{
_links: {
spaceship: {
href: "/v2/ship"
}
}
},
"spaceship"
);
expect(link).toBe("/v2/ship");
});
it("should throw error, if link is missing", () => {
const object = { _links: {} };
expect(() => requiredLink(object, "spaceship")).toThrowError();
});
it("should throw error, if link is array", () => {
const object = {
_links: {
spaceship: [
{
name: "one",
href: "/v2/one"
},
{
name: "two",
href: "/v2/two"
}
]
}
};
expect(() => requiredLink(object, "spaceship")).toThrowError();
});
});

View File

@@ -0,0 +1,38 @@
/*
* 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 { HalRepresentation } from "@scm-manager/ui-types";
import { MissingLinkError } from "./errors";
export const requiredLink = (object: HalRepresentation, name: string) => {
const link = object._links[name];
if (!link) {
throw new MissingLinkError(`could not find link with name ${name}`);
}
if (Array.isArray(link)) {
throw new Error(`could not return href, link ${name} is a multi link`);
}
return link.href;
};

View File

@@ -0,0 +1,239 @@
/*
* 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-jest";
import { renderHook } from "@testing-library/react-hooks";
import { Me } from "@scm-manager/ui-types";
import createWrapper from "./tests/createWrapper";
import { useLogin, useLogout, useMe, useRequiredMe, useSubject } from "./login";
import { setEmptyIndex, setIndexLink } from "./tests/indexLinks";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { LegacyContext } from "./LegacyContext";
import { act } from "react-test-renderer";
describe("Test login hooks", () => {
const tricia: Me = {
name: "tricia",
displayName: "Tricia",
groups: [],
_links: {}
};
describe("useMe tests", () => {
fetchMock.get("/api/v2/me", {
name: "tricia",
displayName: "Tricia",
groups: [],
_links: {}
});
it("should return me", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "me", "/me");
const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(undefined, queryClient) });
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?.name).toEqual("tricia");
});
it("should call onMeFetched of LegacyContext", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "me", "/me");
let me: Me;
const context: LegacyContext = {
onMeFetched: fetchedMe => {
me = fetchedMe;
}
};
const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(context, queryClient) });
await waitFor(() => {
return !!result.current.data;
});
expect(me!.name).toEqual("tricia");
});
it("should return nothing without me link", () => {
const queryClient = createInfiniteCachingClient();
setEmptyIndex(queryClient);
const { result } = renderHook(() => useMe(), { wrapper: createWrapper(undefined, queryClient) });
expect(result.current.isLoading).toBe(false);
expect(result.current?.data).toBeFalsy();
expect(result.current?.error).toBeFalsy();
});
});
describe("useRequiredMe tests", () => {
it("should return me", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData("me", tricia);
setIndexLink(queryClient, "me", "/me");
const { result, waitFor } = renderHook(() => useRequiredMe(), { wrapper: createWrapper(undefined, queryClient) });
await waitFor(() => {
return !!result.current;
});
expect(result.current?.name).toBe("tricia");
});
it("should throw an error if me is not available", () => {
const queryClient = createInfiniteCachingClient();
setEmptyIndex(queryClient);
const { result } = renderHook(() => useRequiredMe(), { wrapper: createWrapper(undefined, queryClient) });
expect(result.error).toBeDefined();
});
});
describe("useSubject tests", () => {
it("should return authenticated subject", () => {
const queryClient = createInfiniteCachingClient();
setEmptyIndex(queryClient);
queryClient.setQueryData("me", tricia);
const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) });
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.isAnonymous).toBe(false);
expect(result.current.me).toEqual(tricia);
});
it("should return anonymous subject", () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "login", "/login");
queryClient.setQueryData("me", {
name: "_anonymous",
displayName: "Anonymous",
groups: [],
_links: {}
});
const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) });
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isAnonymous).toBe(true);
});
it("should return unauthenticated subject", () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "login", "/login");
const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) });
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isAnonymous).toBe(false);
});
});
describe("useLogin tests", () => {
it("should login", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "login", "/login");
fetchMock.post("/api/v2/login", "", {
body: {
cookie: true,
grant_type: "password",
username: "tricia",
password: "hitchhikersSecret!"
}
});
// required because we invalidate the whole cache and react-query refetches the index
fetchMock.get("/api/v2/", {
version: "x.y.z",
_links: {
login: {
href: "/second/login"
}
}
});
const { result, waitForNextUpdate } = renderHook(() => useLogin(), {
wrapper: createWrapper(undefined, queryClient)
});
const { login } = result.current;
expect(login).toBeDefined();
await act(() => {
if (login) {
login("tricia", "hitchhikersSecret!");
}
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
});
it("should not return login, if authenticated", () => {
const queryClient = createInfiniteCachingClient();
setEmptyIndex(queryClient);
queryClient.setQueryData("me", tricia);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current.login).toBeUndefined();
});
});
describe("useLogout tests", () => {
it("should call logout", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "logout", "/logout");
fetchMock.deleteOnce("/api/v2/logout", "");
const { result, waitForNextUpdate } = renderHook(() => useLogout(), {
wrapper: createWrapper(undefined, queryClient)
});
const { logout } = result.current;
expect(logout).toBeDefined();
await act(() => {
if (logout) {
logout();
}
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
});
it("should not return logout without link", () => {
const queryClient = createInfiniteCachingClient();
setEmptyIndex(queryClient);
const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(undefined, queryClient)
});
const { logout } = result.current;
expect(logout).toBeUndefined();
});
});
});

118
scm-ui/ui-api/src/login.ts Normal file
View File

@@ -0,0 +1,118 @@
/*
* 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 { Me } from "@scm-manager/ui-types";
import { useMutation, useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { ApiResult, useIndexLink } from "./base";
import { useLegacyContext } from "./LegacyContext";
import { useReset } from "./reset";
export const useMe = (): ApiResult<Me> => {
const legacy = useLegacyContext();
const link = useIndexLink("me");
return useQuery<Me, Error>("me", () => apiClient.get(link!).then(response => response.json()), {
enabled: !!link,
onSuccess: me => {
if (legacy.onMeFetched) {
legacy.onMeFetched(me);
}
}
});
};
export const useRequiredMe = () => {
const { data } = useMe();
if (!data) {
throw new Error("Could not find 'me' in cache");
}
return data;
};
export const useSubject = () => {
const link = useIndexLink("login");
const { isLoading, error, data: me } = useMe();
const isAnonymous = me?.name === "_anonymous";
const isAuthenticated = !isAnonymous && !!me && !link;
return {
isAuthenticated,
isAnonymous,
isLoading,
error,
me
};
};
type Credentials = {
username: string;
password: string;
cookie: boolean;
grant_type: string;
};
export const useLogin = () => {
const link = useIndexLink("login");
const reset = useReset();
const { mutate, isLoading, error } = useMutation<unknown, Error, Credentials>(
credentials => apiClient.post(link!, credentials),
{
onSuccess: reset
}
);
const login = (username: string, password: string) => {
// grant_type is specified by the oauth standard with the underscore
// so we stick with it, even if eslint does not like it.
// eslint-disable-next-line @typescript-eslint/camelcase
mutate({ cookie: true, grant_type: "password", username, password });
};
return {
login: link ? login : undefined,
isLoading,
error
};
};
export const useLogout = () => {
const link = useIndexLink("logout");
const reset = useReset();
const { mutate, isLoading, error, data } = useMutation<boolean, Error, unknown>(
() => apiClient.delete(link!).then(() => true),
{
onSuccess: reset
}
);
const logout = () => {
mutate({});
};
return {
logout: link && !data ? logout : undefined,
isLoading,
error
};
};

View File

@@ -0,0 +1,97 @@
/*
* 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 createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import fetchMock from "fetch-mock-jest";
import { renderHook } from "@testing-library/react-hooks";
import { useNamespace, useNamespaces, useNamespaceStrategies } from "./namespaces";
import createWrapper from "./tests/createWrapper";
describe("Test namespace hooks", () => {
describe("useNamespaces test", () => {
it("should return namespaces", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "namespaces", "/namespaces");
fetchMock.get("/api/v2/namespaces", {
_embedded: {
namespaces: [
{
namespace: "spaceships",
_links: {}
}
]
}
});
const { result, waitFor } = renderHook(() => useNamespaces(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?._embedded.namespaces[0].namespace).toBe("spaceships");
});
});
describe("useNamespaceStrategies tests", () => {
it("should return namespaces strategies", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "namespaceStrategies", "/ns");
fetchMock.get("/api/v2/ns", {
current: "awesome",
available: [],
_links: {}
});
const { result, waitFor } = renderHook(() => useNamespaceStrategies(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?.current).toEqual("awesome");
});
});
describe("useNamespace tests", () => {
it("should return namespace", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "namespaces", "/ns");
fetchMock.get("/api/v2/ns/awesome", {
namespace: "awesome",
_links: {}
});
const { result, waitFor } = renderHook(() => useNamespace("awesome"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?.namespace).toEqual("awesome");
});
});
});

View File

@@ -0,0 +1,45 @@
/*
* 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, useIndexJsonResource, useRequiredIndexLink } from "./base";
import { Namespace, NamespaceCollection, NamespaceStrategies } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { concat } from "./urls";
export const useNamespaces = () => {
return useIndexJsonResource<NamespaceCollection>("namespaces");
};
export const useNamespace = (name: string): ApiResult<Namespace> => {
const namespacesLink = useRequiredIndexLink("namespaces");
return useQuery<Namespace, Error>(["namespace", name], () =>
apiClient.get(concat(namespacesLink, name)).then(response => response.json())
);
};
export const useNamespaceStrategies = () => {
return useIndexJsonResource<NamespaceStrategies>("namespaceStrategies");
};

View File

@@ -0,0 +1,347 @@
/*
* 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 { setIndexLink } from "./tests/indexLinks";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import {
Namespace,
Permission,
PermissionCollection,
Repository,
RepositoryRole,
RepositoryRoleCollection,
RepositoryVerbs
} from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import {
useAvailablePermissions,
useCreatePermission,
useDeletePermission,
usePermissions,
useRepositoryVerbs,
useUpdatePermission
} from "./permissions";
import { act } from "react-test-renderer";
describe("permission hooks test", () => {
const readRole: RepositoryRole = {
name: "READ",
verbs: ["read", "pull"],
_links: {}
};
const roleCollection: RepositoryRoleCollection = {
_embedded: {
repositoryRoles: [readRole]
},
_links: {},
page: 1,
pageTotal: 1
};
const verbCollection: RepositoryVerbs = {
verbs: ["read", "pull"],
_links: {}
};
const readPermission: Permission = {
name: "trillian",
role: "READ",
verbs: [],
groupPermission: false,
_links: {
update: {
href: "/p/trillian"
}
}
};
const writePermission: Permission = {
name: "dent",
role: "WRITE",
verbs: [],
groupPermission: false,
_links: {
delete: {
href: "/p/dent"
}
}
};
const permissionsRead: PermissionCollection = {
_embedded: {
permissions: [readPermission]
},
_links: {}
};
const permissionsWrite: PermissionCollection = {
_embedded: {
permissions: [writePermission]
},
_links: {}
};
const namespace: Namespace = {
namespace: "spaceships",
_links: {
permissions: {
href: "/ns/spaceships/permissions"
}
}
};
const repository: Repository = {
namespace: "spaceships",
name: "heart-of-gold",
type: "git",
_links: {
permissions: {
href: "/r/heart-of-gold/permissions"
}
}
};
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
fetchMock.reset();
});
describe("useRepositoryVerbs tests", () => {
it("should return available verbs", async () => {
setIndexLink(queryClient, "repositoryVerbs", "/verbs");
fetchMock.get("/api/v2/verbs", verbCollection);
const { result, waitFor } = renderHook(() => useRepositoryVerbs(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data).toEqual(verbCollection);
});
});
describe("useAvailablePermissions tests", () => {
it("should return available roles and verbs", async () => {
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
repositoryRoles: {
href: "/roles"
},
repositoryVerbs: {
href: "/verbs"
}
}
});
fetchMock.get("/api/v2/roles", roleCollection);
fetchMock.get("/api/v2/verbs", verbCollection);
const { result, waitFor } = renderHook(() => useAvailablePermissions(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data?.repositoryRoles).toEqual(roleCollection._embedded.repositoryRoles);
expect(result.current.data?.repositoryVerbs).toEqual(verbCollection.verbs);
});
});
describe("usePermissions tests", () => {
const fetchPermissions = async (namespaceOrRepository: Namespace | Repository) => {
const { result, waitFor } = renderHook(() => usePermissions(namespaceOrRepository), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
return result.current.data;
};
it("should return permissions from namespace", async () => {
fetchMock.getOnce("/api/v2/ns/spaceships/permissions", permissionsRead);
const data = await fetchPermissions(namespace);
expect(data).toEqual(permissionsRead);
});
it("should cache permissions for namespace", async () => {
fetchMock.getOnce("/api/v2/ns/spaceships/permissions", permissionsRead);
await fetchPermissions(namespace);
const data = queryClient.getQueryData(["namespace", "spaceships", "permissions"]);
expect(data).toEqual(permissionsRead);
});
it("should return permissions from repository", async () => {
fetchMock.getOnce("/api/v2/r/heart-of-gold/permissions", permissionsWrite);
const data = await fetchPermissions(repository);
expect(data).toEqual(permissionsWrite);
});
it("should cache permissions for repository", async () => {
fetchMock.getOnce("/api/v2/r/heart-of-gold/permissions", permissionsWrite);
await fetchPermissions(repository);
const data = queryClient.getQueryData(["repository", "spaceships", "heart-of-gold", "permissions"]);
expect(data).toEqual(permissionsWrite);
});
});
describe("useCreatePermission tests", () => {
const createAndFetch = async () => {
fetchMock.postOnce("/api/v2/ns/spaceships/permissions", {
status: 201,
headers: {
Location: "/ns/spaceships/permissions/42"
}
});
fetchMock.getOnce("/api/v2/ns/spaceships/permissions/42", readPermission);
const { result, waitForNextUpdate } = renderHook(() => useCreatePermission(namespace), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(readPermission);
return waitForNextUpdate();
});
return result.current;
};
it("should create permission", async () => {
const data = await createAndFetch();
expect(data.permission).toEqual(readPermission);
});
it("should fail without location header", async () => {
fetchMock.postOnce("/api/v2/ns/spaceships/permissions", {
status: 201
});
const { result, waitForNextUpdate } = renderHook(() => useCreatePermission(namespace), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(readPermission);
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
it("should invalidate namespace cache", async () => {
const key = ["namespace", "spaceships", "permissions"];
queryClient.setQueryData(key, permissionsRead);
await createAndFetch();
const state = queryClient.getQueryState(key);
expect(state?.isInvalidated).toBe(true);
});
});
describe("useDeletePermission tests", () => {
const deletePermission = async () => {
fetchMock.deleteOnce("/api/v2/p/dent", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useDeletePermission(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(writePermission);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await deletePermission();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState?.isInvalidated).toBe(true);
};
it("should delete permission", async () => {
const { isDeleted } = await deletePermission();
expect(isDeleted).toBe(true);
});
it("should invalidate permission cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heart-of-gold", "permissions"], permissionsWrite);
});
});
describe("useUpdatePermission tests", () => {
const updatePermission = async () => {
fetchMock.putOnce("/api/v2/p/trillian", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useUpdatePermission(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(readPermission);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await updatePermission();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState?.isInvalidated).toBe(true);
};
it("should update permission", async () => {
const { isUpdated } = await updatePermission();
expect(isUpdated).toBe(true);
});
it("should invalidate permission cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heart-of-gold", "permissions"], permissionsRead);
});
});
});

View File

@@ -0,0 +1,158 @@
/*
* 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, useIndexJsonResource } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
Namespace,
Permission,
PermissionCollection,
PermissionCreateEntry,
Repository,
RepositoryVerbs
} from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { useRepositoryRoles } from "./repository-roles";
export const useRepositoryVerbs = (): ApiResult<RepositoryVerbs> => {
return useIndexJsonResource<RepositoryVerbs>("repositoryVerbs");
};
export const useAvailablePermissions = () => {
const roles = useRepositoryRoles();
const verbs = useRepositoryVerbs();
let data;
if (roles.data && verbs.data) {
data = {
repositoryVerbs: verbs.data.verbs,
repositoryRoles: roles.data._embedded.repositoryRoles
};
}
return {
isLoading: roles.isLoading || verbs.isLoading,
error: roles.error || verbs.error,
data
};
};
const isRepository = (namespaceOrRepository: Namespace | Repository): namespaceOrRepository is Repository => {
return (namespaceOrRepository as Repository).name !== undefined;
};
const createQueryKey = (namespaceOrRepository: Namespace | Repository) => {
if (isRepository(namespaceOrRepository)) {
return repoQueryKey(namespaceOrRepository, "permissions");
} else {
return ["namespace", namespaceOrRepository.namespace, "permissions"];
}
};
export const usePermissions = (namespaceOrRepository: Namespace | Repository): ApiResult<PermissionCollection> => {
const link = requiredLink(namespaceOrRepository, "permissions");
const queryKey = createQueryKey(namespaceOrRepository);
return useQuery<PermissionCollection, Error>(queryKey, () => apiClient.get(link).then(response => response.json()));
};
const createPermission = (link: string) => {
return (permission: PermissionCreateEntry) => {
return apiClient
.post(link, permission, "application/vnd.scmm-repositoryPermission+json")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreatePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const link = requiredLink(namespaceOrRepository, "permissions");
const { isLoading, error, mutate, data } = useMutation<Permission, Error, PermissionCreateEntry>(
createPermission(link),
{
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
}
);
return {
isLoading,
error,
create: (permission: PermissionCreateEntry) => mutate(permission),
permission: data
};
};
export const useUpdatePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
const link = requiredLink(permission, "update");
return apiClient.put(link, permission, "application/vnd.scmm-repositoryPermission+json");
},
{
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
}
);
return {
isLoading,
error,
update: (permission: Permission) => mutate(permission),
isUpdated: !!data
};
};
export const useDeletePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
const link = requiredLink(permission, "delete");
return apiClient.delete(link);
},
{
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
}
);
return {
isLoading,
error,
remove: (permission: Permission) => mutate(permission),
isDeleted: !!data
};
};

View File

@@ -0,0 +1,317 @@
/*
* 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 { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import fetchMock from "fetch-mock-jest";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import {
useAvailablePlugins,
useInstalledPlugins,
useInstallPlugin,
usePendingPlugins,
useUninstallPlugin,
useUpdatePlugins
} from "./plugins";
import { act } from "react-test-renderer";
describe("Test plugin hooks", () => {
const availablePlugin: Plugin = {
author: "Douglas Adams",
category: "all",
displayName: "Heart of Gold",
version: "x.y.z",
name: "heart-of-gold-plugin",
pending: false,
dependencies: [],
optionalDependencies: [],
_links: {
install: { href: "/plugins/available/heart-of-gold-plugin/install" },
installWithRestart: {
href: "/plugins/available/heart-of-gold-plugin/install?restart=true"
}
}
};
const installedPlugin: Plugin = {
author: "Douglas Adams",
category: "all",
displayName: "Heart of Gold",
version: "x.y.z",
name: "heart-of-gold-plugin",
pending: false,
markedForUninstall: false,
dependencies: [],
optionalDependencies: [],
_links: {
self: {
href: "/plugins/installed/heart-of-gold-plugin"
},
update: {
href: "/plugins/available/heart-of-gold-plugin/install"
},
updateWithRestart: {
href: "/plugins/available/heart-of-gold-plugin/install?restart=true"
},
uninstall: {
href: "/plugins/installed/heart-of-gold-plugin/uninstall"
},
uninstallWithRestart: {
href: "/plugins/installed/heart-of-gold-plugin/uninstall?restart=true"
}
}
};
const installedCorePlugin: Plugin = {
author: "Douglas Adams",
category: "all",
displayName: "Heart of Gold",
version: "x.y.z",
name: "heart-of-gold-core-plugin",
pending: false,
markedForUninstall: false,
dependencies: [],
optionalDependencies: [],
_links: {
self: {
href: "/plugins/installed/heart-of-gold-core-plugin"
}
}
};
const createPluginCollection = (plugins: Plugin[]): PluginCollection => ({
_links: {
update: {
href: "/plugins/update"
}
},
_embedded: {
plugins
}
});
const createPendingPlugins = (
newPlugins: Plugin[] = [],
updatePlugins: Plugin[] = [],
uninstallPlugins: Plugin[] = []
): PendingPlugins => ({
_links: {},
_embedded: {
new: newPlugins,
update: updatePlugins,
uninstall: uninstallPlugins
}
});
afterEach(() => fetchMock.reset());
describe("useAvailablePlugins tests", () => {
it("should return availablePlugins", async () => {
const availablePlugins = createPluginCollection([availablePlugin]);
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "availablePlugins", "/availablePlugins");
fetchMock.get("/api/v2/availablePlugins", availablePlugins);
const { result, waitFor } = renderHook(() => useAvailablePlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(availablePlugins);
});
});
describe("useInstalledPlugins tests", () => {
it("should return installedPlugins", async () => {
const installedPlugins = createPluginCollection([installedPlugin, installedCorePlugin]);
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "installedPlugins", "/installedPlugins");
fetchMock.get("/api/v2/installedPlugins", installedPlugins);
const { result, waitFor } = renderHook(() => useInstalledPlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(installedPlugins);
});
});
describe("usePendingPlugins tests", () => {
it("should return pendingPlugins", async () => {
const pendingPlugins = createPendingPlugins([availablePlugin]);
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "pendingPlugins", "/pendingPlugins");
fetchMock.get("/api/v2/pendingPlugins", pendingPlugins);
const { result, waitFor } = renderHook(() => usePendingPlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(pendingPlugins);
});
});
describe("useInstallPlugin tests", () => {
it("should use restart parameter", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([availablePlugin]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install?restart=true", installedPlugin);
fetchMock.get("/api/v2/", "Restarted");
const { result, waitFor, waitForNextUpdate } = renderHook(() => useInstallPlugin(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { install } = result.current;
install(availablePlugin, { restart: true, initialDelay: 5, timeout: 5 });
return waitForNextUpdate();
});
await waitFor(() => result.current.isInstalled);
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
it("should invalidate query keys", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([availablePlugin]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install", installedPlugin);
const { result, waitForNextUpdate } = renderHook(() => useInstallPlugin(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { install } = result.current;
install(availablePlugin);
return waitForNextUpdate();
});
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
});
describe("useUninstallPlugin tests", () => {
it("should use restart parameter", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/installed/heart-of-gold-plugin/uninstall?restart=true", availablePlugin);
fetchMock.get("/api/v2/", "Restarted");
const { result, waitForNextUpdate, waitFor } = renderHook(() => useUninstallPlugin(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { uninstall } = result.current;
uninstall(installedPlugin, { restart: true, initialDelay: 5, timeout: 5 });
return waitForNextUpdate();
});
await waitFor(() => result.current.isUninstalled);
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
it("should invalidate query keys", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/installed/heart-of-gold-plugin/uninstall", availablePlugin);
const { result, waitForNextUpdate } = renderHook(() => useUninstallPlugin(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { uninstall } = result.current;
uninstall(installedPlugin);
return waitForNextUpdate();
});
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
});
describe("useUpdatePlugins tests", () => {
it("should use restart parameter", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install?restart=true", installedPlugin);
fetchMock.get("/api/v2/", "Restarted");
const { result, waitForNextUpdate, waitFor } = renderHook(() => useUpdatePlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(installedPlugin, { restart: true, timeout: 5, initialDelay: 5 });
return waitForNextUpdate();
});
await waitFor(() => result.current.isUpdated);
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
it("should update collection", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/update", installedPlugin);
const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(createPluginCollection([installedPlugin, installedCorePlugin]));
return waitForNextUpdate();
});
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
it("should ignore restart parameter collection", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/update", installedPlugin);
const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(createPluginCollection([installedPlugin, installedCorePlugin]), { restart: true });
return waitForNextUpdate();
});
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
it("should invalidate query keys", async () => {
const queryClient = createInfiniteCachingClient();
queryClient.setQueryData(["plugins", "available"], createPluginCollection([]));
queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin]));
queryClient.setQueryData(["plugins", "pending"], createPendingPlugins());
fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install", installedPlugin);
const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(installedPlugin);
return waitForNextUpdate();
});
expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true);
});
});
});

View File

@@ -0,0 +1,249 @@
/*
* 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, useIndexLink, useRequiredIndexLink } from "./base";
import { isPluginCollection, PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "@scm-manager/ui-components";
import { requiredLink } from "./links";
type WaitForRestartOptions = {
initialDelay?: number;
timeout?: number;
};
const waitForRestartAfter = (
promise: Promise<any>,
{ initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {}
): Promise<void> => {
const endTime = Number(new Date()) + 60000;
let started = false;
const executor = <T = any>(data: T) => (resolve: (result: T) => void, reject: (error: Error) => void) => {
// we need some initial delay
if (!started) {
started = true;
setTimeout(executor(data), initialDelay, resolve, reject);
} else {
apiClient
.get("")
.then(() => resolve(data))
.catch(() => {
if (Number(new Date()) < endTime) {
setTimeout(executor(data), timeout, resolve, reject);
} else {
reject(new Error("timeout reached"));
}
});
}
};
return promise.then(data => new Promise<void>(executor(data)));
};
export type UseAvailablePluginsOptions = {
enabled?: boolean;
};
export const useAvailablePlugins = ({ enabled }: UseAvailablePluginsOptions = {}): ApiResult<PluginCollection> => {
const indexLink = useRequiredIndexLink("availablePlugins");
return useQuery<PluginCollection, Error>(
["plugins", "available"],
() => apiClient.get(indexLink).then(response => response.json()),
{
enabled,
retry: 3
}
);
};
export type UseInstalledPluginsOptions = {
enabled?: boolean;
};
export const useInstalledPlugins = ({ enabled }: UseInstalledPluginsOptions = {}): ApiResult<PluginCollection> => {
const indexLink = useRequiredIndexLink("installedPlugins");
return useQuery<PluginCollection, Error>(
["plugins", "installed"],
() => apiClient.get(indexLink).then(response => response.json()),
{
enabled,
retry: 3
}
);
};
export const usePendingPlugins = (): ApiResult<PendingPlugins> => {
const indexLink = useIndexLink("pendingPlugins");
return useQuery<PendingPlugins, Error>(
["plugins", "pending"],
() => apiClient.get(indexLink!).then(response => response.json()),
{
enabled: !!indexLink,
retry: 3
}
);
};
const linkWithRestart = (link: string, restart?: boolean) => {
if (restart) {
return link + "WithRestart";
}
return link;
};
type RestartOptions = WaitForRestartOptions & {
restart?: boolean;
};
type PluginActionOptions = {
plugin: Plugin;
restartOptions: RestartOptions;
};
export const useInstallPlugin = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PluginActionOptions>(
({ plugin, restartOptions: { restart, ...waitForRestartOptions } }) => {
const promise = apiClient.post(requiredLink(plugin, linkWithRestart("install", restart)));
if (restart) {
return waitForRestartAfter(promise, waitForRestartOptions);
}
return promise;
},
{
onSuccess: () => queryClient.invalidateQueries("plugins")
}
);
return {
install: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
mutate({
plugin,
restartOptions
}),
isLoading,
error,
data,
isInstalled: !!data
};
};
export const useUninstallPlugin = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PluginActionOptions>(
({ plugin, restartOptions: { restart, ...waitForRestartOptions } }) => {
const promise = apiClient.post(requiredLink(plugin, linkWithRestart("uninstall", restart)));
if (restart) {
return waitForRestartAfter(promise, waitForRestartOptions);
}
return promise;
},
{
onSuccess: () => queryClient.invalidateQueries("plugins")
}
);
return {
uninstall: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
mutate({
plugin,
restartOptions
}),
isLoading,
error,
isUninstalled: !!data
};
};
type UpdatePluginsOptions = {
plugins: Plugin | PluginCollection;
restartOptions: RestartOptions;
};
export const useUpdatePlugins = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, UpdatePluginsOptions>(
({ plugins, restartOptions: { restart, ...waitForRestartOptions } }) => {
const isCollection = isPluginCollection(plugins);
const promise = apiClient.post(
requiredLink(plugins, isCollection ? "update" : linkWithRestart("update", restart))
);
if (restart && !isCollection) {
return waitForRestartAfter(promise, waitForRestartOptions);
}
return promise;
},
{
onSuccess: () => queryClient.invalidateQueries("plugins")
}
);
return {
update: (plugin: Plugin | PluginCollection, restartOptions: RestartOptions = {}) =>
mutate({
plugins: plugin,
restartOptions
}),
isLoading,
error,
isUpdated: !!data
};
};
type ExecutePendingPlugins = {
pending: PendingPlugins;
restartOptions: WaitForRestartOptions;
};
export const useExecutePendingPlugins = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, ExecutePendingPlugins>(
({ pending, restartOptions }) =>
waitForRestartAfter(apiClient.post(requiredLink(pending, "execute")), restartOptions),
{
onSuccess: () => queryClient.invalidateQueries("plugins")
}
);
return {
update: (pending: PendingPlugins, restartOptions: WaitForRestartOptions = {}) =>
mutate({ pending, restartOptions }),
isLoading,
error,
isExecuted: !!data
};
};
export const useCancelPendingPlugins = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PendingPlugins>(
pending => apiClient.post(requiredLink(pending, "cancel")),
{
onSuccess: () => queryClient.invalidateQueries("plugins")
}
);
return {
update: (pending: PendingPlugins) => mutate(pending),
isLoading,
error,
isCancelled: !!data
};
};

View File

@@ -0,0 +1,517 @@
/*
* 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-jest";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { setIndexLink } from "./tests/indexLinks";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import {
useArchiveRepository,
useCreateRepository,
useDeleteRepository,
UseDeleteRepositoryOptions,
useRepositories,
UseRepositoriesRequest,
useRepository,
useRepositoryTypes,
useUnarchiveRepository,
useUpdateRepository
} from "./repositories";
import { Repository } from "@scm-manager/ui-types";
import { QueryClient } from "react-query";
import { act } from "react-test-renderer";
describe("Test repository hooks", () => {
const heartOfGold: Repository = {
namespace: "spaceships",
name: "heartOfGold",
type: "git",
_links: {
delete: {
href: "/r/spaceships/heartOfGold"
},
update: {
href: "/r/spaceships/heartOfGold"
},
archive: {
href: "/r/spaceships/heartOfGold/archive"
},
unarchive: {
href: "/r/spaceships/heartOfGold/unarchive"
}
}
};
const repositoryCollection = {
_embedded: {
repositories: [heartOfGold]
},
_links: {}
};
afterEach(() => {
fetchMock.reset();
});
describe("useRepositories tests", () => {
const expectCollection = async (queryClient: QueryClient, request?: UseRepositoriesRequest) => {
const { result, waitFor } = renderHook(() => useRepositories(request), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data).toEqual(repositoryCollection);
};
it("should return repositories", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
fetchMock.get("/api/v2/repos", repositoryCollection, {
query: {
sortBy: "namespaceAndName"
}
});
await expectCollection(queryClient);
});
it("should return repositories with page", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
fetchMock.get("/api/v2/repos", repositoryCollection, {
query: {
sortBy: "namespaceAndName",
page: "42"
}
});
await expectCollection(queryClient, {
page: 42
});
});
it("should use repository from namespace", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
fetchMock.get("/api/v2/spaceships", repositoryCollection, {
query: {
sortBy: "namespaceAndName"
}
});
await expectCollection(queryClient, {
namespace: {
namespace: "spaceships",
_links: {
repositories: {
href: "/spaceships"
}
}
}
});
});
it("should append search query", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
fetchMock.get("/api/v2/repos", repositoryCollection, {
query: {
sortBy: "namespaceAndName",
q: "heart"
}
});
await expectCollection(queryClient, {
search: "heart"
});
});
it("should update repository cache", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
fetchMock.get("/api/v2/repos", repositoryCollection, {
query: {
sortBy: "namespaceAndName"
}
});
await expectCollection(queryClient);
const repository = queryClient.getQueryData(["repository", "spaceships", "heartOfGold"]);
expect(repository).toEqual(heartOfGold);
});
it("should return nothing if disabled", () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/repos");
const { result } = renderHook(() => useRepositories({ disabled: true }), {
wrapper: createWrapper(undefined, queryClient)
});
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeFalsy();
expect(result.current.error).toBeFalsy();
});
});
describe("useCreateRepository tests", () => {
it("should create repository", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/r");
fetchMock.postOnce("/api/v2/r", {
status: 201,
headers: {
Location: "/r/spaceships/heartOfGold"
}
});
fetchMock.getOnce("/api/v2/r/spaceships/heartOfGold", heartOfGold);
const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
const repository = {
...heartOfGold,
contextEntries: []
};
await act(() => {
const { create } = result.current;
create(repository, false);
return waitForNextUpdate();
});
expect(result.current.repository).toEqual(heartOfGold);
});
it("should append initialize param", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/r");
fetchMock.postOnce("/api/v2/r?initialize=true", {
status: 201,
headers: {
Location: "/r/spaceships/heartOfGold"
}
});
fetchMock.getOnce("/api/v2/r/spaceships/heartOfGold", heartOfGold);
const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
const repository = {
...heartOfGold,
contextEntries: []
};
await act(() => {
const { create } = result.current;
create(repository, true);
return waitForNextUpdate();
});
expect(result.current.repository).toEqual(heartOfGold);
});
it("should fail without location header", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/r");
fetchMock.postOnce("/api/v2/r", {
status: 201
});
const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
const repository = {
...heartOfGold,
contextEntries: []
};
await act(() => {
const { create } = result.current;
create(repository, false);
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
});
describe("useRepository tests", () => {
it("should return repository", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositories", "/r");
fetchMock.get("/api/v2/r/spaceships/heartOfGold", heartOfGold);
const { result, waitFor } = renderHook(() => useRepository("spaceships", "heartOfGold"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current?.data?.type).toEqual("git");
});
});
describe("useRepositoryTypes tests", () => {
it("should return repository types", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryTypes", "/rt");
fetchMock.get("/api/v2/rt", {
_embedded: {
repositoryTypes: [
{
name: "git",
displayName: "Git",
_links: {}
}
]
},
_links: {}
});
const { result, waitFor } = renderHook(() => useRepositoryTypes(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
expect(result.current.data).toBeDefined();
if (result.current?.data) {
expect(result.current?.data._embedded.repositoryTypes[0].name).toEqual("git");
}
});
});
describe("useDeleteRepository tests", () => {
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
});
const deleteRepository = async (options?: UseDeleteRepositoryOptions) => {
fetchMock.deleteOnce("/api/v2/r/spaceships/heartOfGold", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteRepository(options), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(heartOfGold);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await deleteRepository();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should delete repository", async () => {
const { isDeleted } = await deleteRepository();
expect(isDeleted).toBe(true);
});
it("should invalidate repository cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold);
});
it("should invalidate repository collection cache", async () => {
await shouldInvalidateQuery(["repositories"], repositoryCollection);
});
it("should call onSuccess callback", async () => {
let repo;
await deleteRepository({
onSuccess: repository => {
repo = repository;
}
});
expect(repo).toEqual(heartOfGold);
});
});
describe("useUpdateRepository tests", () => {
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
});
const updateRepository = async () => {
fetchMock.putOnce("/api/v2/r/spaceships/heartOfGold", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useUpdateRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(heartOfGold);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await updateRepository();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should update repository", async () => {
const { isUpdated } = await updateRepository();
expect(isUpdated).toBe(true);
});
it("should invalidate repository cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold);
});
it("should invalidate repository collection cache", async () => {
await shouldInvalidateQuery(["repositories"], repositoryCollection);
});
});
describe("useArchiveRepository tests", () => {
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
});
const archiveRepository = async () => {
fetchMock.postOnce("/api/v2/r/spaceships/heartOfGold/archive", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useArchiveRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { archive } = result.current;
archive(heartOfGold);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await archiveRepository();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should archive repository", async () => {
const { isArchived } = await archiveRepository();
expect(isArchived).toBe(true);
});
it("should invalidate repository cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold);
});
it("should invalidate repository collection cache", async () => {
await shouldInvalidateQuery(["repositories"], repositoryCollection);
});
});
describe("useUnarchiveRepository tests", () => {
const queryClient = createInfiniteCachingClient();
beforeEach(() => {
queryClient.clear();
});
const unarchiveRepository = async () => {
fetchMock.postOnce("/api/v2/r/spaceships/heartOfGold/unarchive", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useUnarchiveRepository(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { unarchive } = result.current;
unarchive(heartOfGold);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await unarchiveRepository();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should unarchive repository", async () => {
const { isUnarchived } = await unarchiveRepository();
expect(isUnarchived).toBe(true);
});
it("should invalidate repository cache", async () => {
await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold);
});
it("should invalidate repository collection cache", async () => {
await shouldInvalidateQuery(["repositories"], repositoryCollection);
});
});
});

View File

@@ -0,0 +1,229 @@
/*
* 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 {
Link,
Namespace,
Repository,
RepositoryCollection,
RepositoryCreation,
RepositoryTypeCollection,
} from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { ApiResult, useIndexJsonResource, useRequiredIndexLink } from "./base";
import { createQueryString } from "./utils";
import { requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { concat } from "./urls";
export type UseRepositoriesRequest = {
namespace?: Namespace;
search?: string;
page?: number | string;
disabled?: boolean;
};
export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<RepositoryCollection> => {
const queryClient = useQueryClient();
const indexLink = useRequiredIndexLink("repositories");
const namespaceLink = (request?.namespace?._links.repositories as Link)?.href;
const link = namespaceLink || indexLink;
const queryParams: Record<string, string> = {
sortBy: "namespaceAndName"
};
if (request?.search) {
queryParams.q = request.search;
}
if (request?.page) {
queryParams.page = request.page.toString();
}
return useQuery<RepositoryCollection, Error>(
["repositories", request?.namespace?.namespace, request?.search || "", request?.page || 0],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then(response => response.json()),
{
enabled: !request?.disabled,
onSuccess: (repositories: RepositoryCollection) => {
// prepare single repository cache
repositories._embedded.repositories.forEach((repository: Repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
});
}
}
);
};
type CreateRepositoryRequest = {
repository: RepositoryCreation;
initialize: boolean;
};
const createRepository = (link: string) => {
return (request: CreateRepositoryRequest) => {
let createLink = link;
if (request.initialize) {
createLink += "?initialize=true";
}
return apiClient
.post(createLink, request.repository, "application/vnd.scmm-repository+json;v=2")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateRepository = () => {
const queryClient = useQueryClient();
// not really the index link,
// but a post to the collection is create by convention
const link = useRequiredIndexLink("repositories");
const { mutate, data, isLoading, error } = useMutation<Repository, Error, CreateRepositoryRequest>(
createRepository(link),
{
onSuccess: repository => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
return queryClient.invalidateQueries(["repositories"]);
}
}
);
return {
create: (repository: RepositoryCreation, initialize: boolean) => {
mutate({ repository, initialize });
},
isLoading,
error,
repository: data
};
};
// TODO increase staleTime, infinite?
export const useRepositoryTypes = () => useIndexJsonResource<RepositoryTypeCollection>("repositoryTypes");
export const useRepository = (namespace: string, name: string): ApiResult<Repository> => {
const link = useRequiredIndexLink("repositories");
return useQuery<Repository, Error>(["repository", namespace, name], () =>
apiClient.get(concat(link, namespace, name)).then(response => response.json())
);
};
export type UseDeleteRepositoryOptions = {
onSuccess: (repository: Repository) => void;
};
export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
const link = requiredLink(repository, "delete");
return apiClient.delete(link);
},
{
onSuccess: async (_, repository) => {
if (options?.onSuccess) {
options.onSuccess(repository);
}
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
}
);
return {
remove: (repository: Repository) => mutate(repository),
isLoading,
error,
isDeleted: !!data
};
};
export const useUpdateRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
const link = requiredLink(repository, "update");
return apiClient.put(link, repository, "application/vnd.scmm-repository+json;v=2");
},
{
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
}
);
return {
update: (repository: Repository) => mutate(repository),
isLoading,
error,
isUpdated: !!data
};
};
export const useArchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
const link = requiredLink(repository, "archive");
return apiClient.post(link);
},
{
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
}
);
return {
archive: (repository: Repository) => mutate(repository),
isLoading,
error,
isArchived: !!data
};
};
export const useUnarchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
const link = requiredLink(repository, "unarchive");
return apiClient.post(link);
},
{
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
}
);
return {
unarchive: (repository: Repository) => mutate(repository),
isLoading,
error,
isUnarchived: !!data
};
};

View File

@@ -0,0 +1,228 @@
/*
* 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 { RepositoryRole, RepositoryRoleCollection } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { act } from "react-test-renderer";
import {
useCreateRepositoryRole,
useDeleteRepositoryRole,
useRepositoryRole, useRepositoryRoles,
useUpdateRepositoryRole
} from "./repository-roles";
describe("Test repository-roles hooks", () => {
const roleName = "theroleingstones";
const role: RepositoryRole = {
name: roleName,
verbs: ["rocking"],
_links: {
delete: {
href: "/repositoryRoles/theroleingstones"
},
update: {
href: "/repositoryRoles/theroleingstones"
}
}
};
const roleCollection: RepositoryRoleCollection = {
page: 0,
pageTotal: 0,
_links: {},
_embedded: {
repositoryRoles: [role]
}
};
afterEach(() => {
fetchMock.reset();
});
describe("useRepositoryRoles tests", () => {
it("should return repositoryRoles", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.get("/api/v2/repositoryRoles", roleCollection);
const { result, waitFor } = renderHook(() => useRepositoryRoles(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(roleCollection);
});
it("should return paged repositoryRoles", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.get("/api/v2/repositoryRoles", roleCollection, {
query: {
page: "42"
}
});
const { result, waitFor } = renderHook(() => useRepositoryRoles({ page: 42 }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(roleCollection);
});
it("should update repositoryRole cache", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.get("/api/v2/repositoryRoles", roleCollection);
const { result, waitFor } = renderHook(() => useRepositoryRoles(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(queryClient.getQueryData(["repositoryRole", roleName])).toEqual(role);
});
});
describe("useRepositoryRole tests", () => {
it("should return repositoryRole", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.get("/api/v2/repositoryRoles/" + roleName, role);
const { result, waitFor } = renderHook(() => useRepositoryRole(roleName), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(role);
});
});
describe("useCreateRepositoryRole tests", () => {
it("should create repositoryRole", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.postOnce("/api/v2/repositoryRoles", {
status: 201,
headers: {
Location: "/repositoryRoles/" + roleName
}
});
fetchMock.getOnce("/api/v2/repositoryRoles/" + roleName, role);
const { result, waitForNextUpdate } = renderHook(() => useCreateRepositoryRole(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(role);
return waitForNextUpdate();
});
expect(result.current.repositoryRole).toEqual(role);
});
it("should fail without location header", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.postOnce("/api/v2/repositoryRoles", {
status: 201
});
fetchMock.getOnce("/api/v2/repositoryRoles/" + roleName, role);
const { result, waitForNextUpdate } = renderHook(() => useCreateRepositoryRole(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(role);
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
});
describe("useDeleteRepositoryRole tests", () => {
it("should delete repositoryRole", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
fetchMock.deleteOnce("/api/v2/repositoryRoles/" + roleName, {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteRepositoryRole(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(role);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isDeleted).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["repositoryRole", roleName])).toBeUndefined();
});
});
describe("useUpdateRepositoryRole tests", () => {
it("should update repositoryRole", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles");
const newRole: RepositoryRole = {
...role,
name: "newname"
};
fetchMock.putOnce("/api/v2/repositoryRoles/" + roleName, {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useUpdateRepositoryRole(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(newRole);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isUpdated).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["repositoryRole", roleName])).toBeUndefined();
expect(queryClient.getQueryData(["repositoryRole", "newname"])).toBeUndefined();
expect(queryClient.getQueryData(["repositoryRoles"])).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,118 @@
import { ApiResult, useRequiredIndexLink } from "./base";
import { RepositoryRole, RepositoryRoleCollection, RepositoryRoleCreation } from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient, urls } from "@scm-manager/ui-components";
import { createQueryString } from "./utils";
import { requiredLink } from "./links";
export type UseRepositoryRolesRequest = {
page?: number | string;
};
export const useRepositoryRoles = (request?: UseRepositoryRolesRequest): ApiResult<RepositoryRoleCollection> => {
const queryClient = useQueryClient();
const indexLink = useRequiredIndexLink("repositoryRoles");
const queryParams: Record<string, string> = {};
if (request?.page) {
queryParams.page = request.page.toString();
}
return useQuery<RepositoryRoleCollection, Error>(
["repositoryRoles", request?.page || 0],
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()),
{
onSuccess: (repositoryRoles: RepositoryRoleCollection) => {
repositoryRoles._embedded.repositoryRoles.forEach((repositoryRole: RepositoryRole) =>
queryClient.setQueryData(["repositoryRole", repositoryRole.name], repositoryRole)
);
}
}
);
};
export const useRepositoryRole = (name: string): ApiResult<RepositoryRole> => {
const indexLink = useRequiredIndexLink("repositoryRoles");
return useQuery<RepositoryRole, Error>(["repositoryRole", name], () =>
apiClient.get(urls.concat(indexLink, name)).then(response => response.json())
);
};
const createRepositoryRole = (link: string) => {
return (repositoryRole: RepositoryRoleCreation) => {
return apiClient
.post(link, repositoryRole, "application/vnd.scmm-repositoryRole+json;v=2")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateRepositoryRole = () => {
const queryClient = useQueryClient();
const link = useRequiredIndexLink("repositoryRoles");
const { mutate, data, isLoading, error } = useMutation<RepositoryRole, Error, RepositoryRoleCreation>(
createRepositoryRole(link),
{
onSuccess: repositoryRole => {
queryClient.setQueryData(["repositoryRole", repositoryRole.name], repositoryRole);
return queryClient.invalidateQueries(["repositoryRoles"]);
}
}
);
return {
create: (repositoryRole: RepositoryRoleCreation) => mutate(repositoryRole),
isLoading,
error,
repositoryRole: data
};
};
export const useUpdateRepositoryRole = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, RepositoryRole>(
repositoryRole => {
const updateUrl = requiredLink(repositoryRole, "update");
return apiClient.put(updateUrl, repositoryRole, "application/vnd.scmm-repositoryRole+json;v=2");
},
{
onSuccess: async (_, repositoryRole) => {
await queryClient.invalidateQueries(["repositoryRole", repositoryRole.name]);
await queryClient.invalidateQueries(["repositoryRoles"]);
}
}
);
return {
update: (repositoryRole: RepositoryRole) => mutate(repositoryRole),
isLoading,
error,
isUpdated: !!data
};
};
export const useDeleteRepositoryRole = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, RepositoryRole>(
repositoryRole => {
const deleteUrl = requiredLink(repositoryRole, "delete");
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, name) => {
await queryClient.invalidateQueries(["repositoryRole", name]);
await queryClient.invalidateQueries(["repositoryRoles"]);
}
}
);
return {
remove: (repositoryRole: RepositoryRole) => mutate(repositoryRole),
isLoading,
error,
isDeleted: !!data
};
};

View File

@@ -0,0 +1,38 @@
/*
* 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 { QueryClient, useQueryClient } from "react-query";
export const reset = (queryClient: QueryClient) => {
queryClient.removeQueries({
predicate: ({ queryKey }) => queryKey !== "index"
});
return queryClient.invalidateQueries("index");
};
export const useReset = () => {
const queryClient = useQueryClient();
return () => reset(queryClient);
};

View File

@@ -0,0 +1,235 @@
/*
* 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, Repository } from "@scm-manager/ui-types";
import { useSources } from "./sources";
import fetchMock from "fetch-mock";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { act, renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
describe("Test sources hooks", () => {
const puzzle42: Repository = {
namespace: "puzzles",
name: "42",
type: "git",
_links: {
sources: {
href: "/src"
}
}
};
const readmeMd: File = {
name: "README.md",
path: "README.md",
directory: false,
revision: "abc",
length: 21,
description: "Awesome readme",
_links: {},
_embedded: {
children: []
}
};
const rootDirectory: File = {
name: "",
path: "",
directory: true,
revision: "abc",
_links: {},
_embedded: {
children: [readmeMd]
}
};
const sepecialMd: File = {
name: "special.md",
path: "main/special.md",
directory: false,
revision: "abc",
length: 42,
description: "Awesome special file",
_links: {},
_embedded: {
children: []
}
};
const sepecialMdPartial: File = {
...sepecialMd,
partialResult: true,
computationAborted: false
};
const sepecialMdComputationAborted: File = {
...sepecialMd,
partialResult: true,
computationAborted: true
};
const mainDirectoryTruncated: File = {
name: "main",
path: "main",
directory: true,
revision: "abc",
truncated: true,
_links: {
proceed: {
href: "src/2"
}
},
_embedded: {
children: []
}
};
const mainDirectory: File = {
...mainDirectoryTruncated,
truncated: false,
_embedded: {
children: [sepecialMd]
}
};
beforeEach(() => {
fetchMock.reset();
});
const firstChild = (directory?: File) => {
if (directory?._embedded.children && directory._embedded.children.length > 0) {
return directory._embedded.children[0];
}
};
describe("useSources tests", () => {
it("should return root directory", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.getOnce("/api/v2/src", rootDirectory);
const { result, waitFor } = renderHook(() => useSources(puzzle42), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(rootDirectory);
});
it("should return file from url with revision and path", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.getOnce("/api/v2/src/abc/README.md", readmeMd);
const { result, waitFor } = renderHook(() => useSources(puzzle42, { revision: "abc", path: "README.md" }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(readmeMd);
});
it("should fetch next page", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.getOnce("/api/v2/src", mainDirectoryTruncated);
fetchMock.getOnce("/api/v2/src/2", mainDirectory);
const { result, waitFor, waitForNextUpdate } = renderHook(() => useSources(puzzle42), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(mainDirectoryTruncated);
await act(() => {
const { fetchNextPage } = result.current;
fetchNextPage();
return waitForNextUpdate();
});
await waitFor(() => !result.current.isFetchingNextPage);
expect(result.current.data).toEqual(mainDirectory);
});
it("should refetch if partial files exists", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.get(
"/api/v2/src",
{
...mainDirectory,
_embedded: {
children: [sepecialMdPartial]
}
},
{
repeat: 1
}
);
fetchMock.get(
"/api/v2/src",
{
...mainDirectory,
_embedded: {
children: [sepecialMd]
}
},
{
repeat: 1,
overwriteRoutes: false
}
);
const { result, waitFor } = renderHook(() => useSources(puzzle42, { refetchPartialInterval: 100 }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!firstChild(result.current.data));
expect(firstChild(result.current.data)?.partialResult).toBe(true);
await waitFor(() => !firstChild(result.current.data)?.partialResult);
expect(firstChild(result.current.data)?.partialResult).toBeFalsy();
});
it("should not refetch if computation is aborted", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMdComputationAborted, { repeat: 1 });
// should never be called
fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMd, {
repeat: 1,
overwriteRoutes: false
});
const { result, waitFor } = renderHook(
() =>
useSources(puzzle42, {
revision: "abc",
path: "main/special.md",
refetchPartialInterval: 100
}),
{
wrapper: createWrapper(undefined, queryClient)
}
);
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(sepecialMdComputationAborted);
await new Promise(r => setTimeout(r, 200));
expect(result.current.data).toEqual(sepecialMdComputationAborted);
});
});
});

View File

@@ -0,0 +1,131 @@
/*
* 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, Link, Repository } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
import { apiClient, urls } from "@scm-manager/ui-components";
import { useInfiniteQuery } from "react-query";
import { repoQueryKey } from "./keys";
import { useEffect } from "react";
export type UseSourcesOptions = {
revision?: string;
path?: string;
refetchPartialInterval?: number;
enabled?: boolean;
};
const UseSourcesDefaultOptions: UseSourcesOptions = {
enabled: true,
refetchPartialInterval: 3000
};
export const useSources = (repository: Repository, opts: UseSourcesOptions = UseSourcesDefaultOptions) => {
const options = {
...UseSourcesDefaultOptions,
...opts
};
const link = createSourcesLink(repository, options);
const { isLoading, error, data, isFetchingNextPage, fetchNextPage, refetch } = useInfiniteQuery<File, Error, File>(
repoQueryKey(repository, "sources", options.revision || "", options.path || ""),
({ pageParam }) => {
return apiClient.get(pageParam || link).then(response => response.json());
},
{
enabled: options.enabled,
getNextPageParam: lastPage => {
return (lastPage._links.proceed as Link)?.href;
}
}
);
const file = merge(data?.pages);
useEffect(() => {
const intervalId = setInterval(() => {
if (isPartial(file)) {
refetch({
throwOnError: true
});
}
}, options.refetchPartialInterval);
return () => clearInterval(intervalId);
}, [options.refetchPartialInterval, file]);
return {
isLoading,
error,
data: file,
isFetchingNextPage,
fetchNextPage: () => {
// wrapped because we do not want to leak react-query types in our api
fetchNextPage();
}
};
};
const createSourcesLink = (repository: Repository, options: UseSourcesOptions) => {
let link = requiredLink(repository, "sources");
if (options.revision) {
link = urls.concat(link, encodeURIComponent(options.revision));
if (options.path) {
link = urls.concat(link, options.path);
}
}
return link;
};
const merge = (files?: File[]): File | undefined => {
if (!files || files.length === 0) {
return;
}
const children = [];
for (const page of files) {
children.push(...(page._embedded?.children || []));
}
const lastPage = files[files.length - 1];
return {
...lastPage,
_embedded: {
...lastPage._embedded,
children
}
};
};
const isFilePartial = (f: File) => {
return f.partialResult && !f.computationAborted;
};
const isPartial = (file?: File) => {
if (!file) {
return false;
}
if (isFilePartial(file)) {
return true;
}
return file._embedded?.children?.some(isFilePartial);
};

View File

@@ -0,0 +1,266 @@
/*
* 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 { Changeset, Repository, Tag, TagCollection } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { useCreateTag, useDeleteTag, useTag, useTags } from "./tags";
import { act } from "react-test-renderer";
describe("Test Tag hooks", () => {
const repository: Repository = {
namespace: "hitchhiker",
name: "heart-of-gold",
type: "git",
_links: {
tags: {
href: "/hog/tags"
}
}
};
const changeset: Changeset = {
id: "42",
description: "Awesome change",
date: new Date(),
author: {
name: "Arthur Dent"
},
_embedded: {},
_links: {
tag: {
href: "/hog/tag"
}
}
};
const tagOneDotZero = {
name: "1.0",
revision: "42",
signatures: [],
_links: {
"delete": {
href: "/hog/tags/1.0"
}
}
};
const tags: TagCollection = {
_embedded: {
tags: [tagOneDotZero]
},
_links: {}
};
const queryClient = createInfiniteCachingClient();
beforeEach(() => queryClient.clear());
afterEach(() => {
fetchMock.reset();
});
describe("useTags tests", () => {
const fetchTags = async () => {
fetchMock.getOnce("/api/v2/hog/tags", tags);
const { result, waitFor } = renderHook(() => useTags(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
return result.current;
};
it("should return tags", async () => {
const { data } = await fetchTags();
expect(data).toEqual(tags);
});
it("should cache tag collection", async () => {
await fetchTags();
const cachedTags = queryClient.getQueryData(["repository", "hitchhiker", "heart-of-gold", "tags"]);
expect(cachedTags).toEqual(tags);
});
});
describe("useTag tests", () => {
const fetchTag = async () => {
fetchMock.getOnce("/api/v2/hog/tags/1.0", tagOneDotZero);
const { result, waitFor } = renderHook(() => useTag(repository, "1.0"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => {
return !!result.current.data;
});
return result.current;
};
it("should return tag", async () => {
const { data } = await fetchTag();
expect(data).toEqual(tagOneDotZero);
});
it("should cache tag", async () => {
await fetchTag();
const cachedTag = queryClient.getQueryData(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"]);
expect(cachedTag).toEqual(tagOneDotZero);
});
});
describe("useCreateTags tests", () => {
const createTag = async () => {
fetchMock.postOnce("/api/v2/hog/tag", {
status: 201,
headers: {
Location: "/hog/tags/1.0"
}
});
fetchMock.getOnce("/api/v2/hog/tags/1.0", tagOneDotZero);
const { result, waitForNextUpdate } = renderHook(() => useCreateTag(repository, changeset), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create("1.0");
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await createTag();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should create tag", async () => {
const { tag } = await createTag();
expect(tag).toEqual(tagOneDotZero);
});
it("should cache tag", async () => {
await createTag();
const cachedTag = queryClient.getQueryData<Tag>(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"]);
expect(cachedTag).toEqual(tagOneDotZero);
});
it("should invalidate tag collection cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tags"], tags);
});
it("should invalidate changeset cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changeset", "42"], changeset);
});
it("should invalidate changeset collection cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changesets"], [changeset]);
});
it("should fail without location header", async () => {
fetchMock.postOnce("/api/v2/hog/tag", {
status: 201
});
const { result, waitForNextUpdate } = renderHook(() => useCreateTag(repository, changeset), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create("awesome-42");
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
});
describe("useDeleteTags tests", () => {
const deleteTag = async () => {
fetchMock.deleteOnce("/api/v2/hog/tags/1.0", {
status: 204
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteTag(repository), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(tagOneDotZero);
return waitForNextUpdate();
});
return result.current;
};
const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => {
queryClient.setQueryData(queryKey, data);
await deleteTag();
const queryState = queryClient.getQueryState(queryKey);
expect(queryState!.isInvalidated).toBe(true);
};
it("should delete tag", async () => {
const { isDeleted } = await deleteTag();
expect(isDeleted).toBe(true);
});
it("should invalidate tag cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"], tagOneDotZero);
});
it("should invalidate tag collection cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tags"], tags);
});
it("should invalidate changeset cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changeset", "42"], changeset);
});
it("should invalidate changeset collection cache", async () => {
await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changesets"], [changeset]);
});
});
});

119
scm-ui/ui-api/src/tags.ts Normal file
View File

@@ -0,0 +1,119 @@
/*
* 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 { Changeset, Link, NamespaceAndName, Repository, Tag, TagCollection } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
import { QueryClient, useMutation, useQuery, useQueryClient } from "react-query";
import { ApiResult } from "./base";
import { repoQueryKey } from "./keys";
import { apiClient } from "./apiclient";
import { concat } from "./urls";
const tagQueryKey = (repository: NamespaceAndName, tag: string) => {
return repoQueryKey(repository, "tag", tag);
};
export const useTags = (repository: Repository): ApiResult<TagCollection> => {
const link = requiredLink(repository, "tags");
return useQuery<TagCollection, Error>(
repoQueryKey(repository, "tags"),
() => apiClient.get(link).then(response => response.json())
// we do not populate the cache for a single tag,
// because we have no pagination for tags and if we have a lot of them
// the population slows us down
);
};
export const useTag = (repository: Repository, name: string): ApiResult<Tag> => {
const link = requiredLink(repository, "tags");
return useQuery<Tag, Error>(tagQueryKey(repository, name), () =>
apiClient.get(concat(link, name)).then(response => response.json())
);
};
const invalidateCacheForTag = (queryClient: QueryClient, repository: NamespaceAndName, tag: Tag) => {
return Promise.all([
queryClient.invalidateQueries(repoQueryKey(repository, "tags")),
queryClient.invalidateQueries(tagQueryKey(repository, tag.name)),
queryClient.invalidateQueries(repoQueryKey(repository, "changesets")),
queryClient.invalidateQueries(repoQueryKey(repository, "changeset", tag.revision))
]);
};
const createTag = (changeset: Changeset, link: string) => {
return (name: string) => {
return apiClient
.post(link, {
name,
revision: changeset.id
})
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateTag = (repository: Repository, changeset: Changeset) => {
const queryClient = useQueryClient();
const link = requiredLink(changeset, "tag");
const { isLoading, error, mutate, data } = useMutation<Tag, Error, string>(createTag(changeset, link), {
onSuccess: async tag => {
queryClient.setQueryData(tagQueryKey(repository, tag.name), tag);
await invalidateCacheForTag(queryClient, repository, tag);
}
});
return {
isLoading,
error,
create: (name: string) => mutate(name),
tag: data
};
};
export const useDeleteTag = (repository: Repository) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Tag>(
tag => {
const deleteUrl = (tag._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, tag) => {
await invalidateCacheForTag(queryClient, repository, tag);
}
}
);
return {
remove: (tag: Tag) => mutate(tag),
isLoading,
error,
isDeleted: !!data
};
};

View File

@@ -22,20 +22,16 @@
* SOFTWARE.
*/
import React from "react";
import {createStore} from "redux";
import { Provider } from 'react-redux'
import { QueryClient } from "react-query";
const reducer = (state, action) => {
return state;
const createInfiniteCachingClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity
}
}
});
};
const withRedux = (storyFn) => {
return React.createElement(Provider, {
store: createStore(reducer, {}),
children: storyFn()
});
}
export default withRedux;
export default createInfiniteCachingClient;

View File

@@ -0,0 +1,37 @@
/*
* 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 { LegacyContext, LegacyContextProvider } from "../LegacyContext";
import { QueryClient, QueryClientProvider } from "react-query";
const createWrapper = (context?: LegacyContext, queryClient?: QueryClient): FC => {
return ({ children }) => (
<QueryClientProvider client={queryClient ? queryClient : new QueryClient()}>
<LegacyContextProvider {...context}>{children}</LegacyContextProvider>
</QueryClientProvider>
);
};
export default createWrapper;

View File

@@ -0,0 +1,43 @@
/*
* 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 { QueryClient} from "react-query";
export const setIndexLink = (queryClient: QueryClient, name: string, href: string) => {
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {
[name]: {
href: href
}
}
});
};
export const setEmptyIndex = (queryClient: QueryClient) => {
queryClient.setQueryData("index", {
version: "x.y.z",
_links: {}
});
};

View File

@@ -23,7 +23,6 @@
*/
import queryString from "query-string";
import { RouteComponentProps } from "react-router-dom";
//@ts-ignore
export const contextPath = window.ctxPath || "";
@@ -93,7 +92,7 @@ export function matchedUrlFromMatch(match: any) {
return stripEndingSlash(match.url);
}
export function matchedUrl(props: RouteComponentProps) {
export function matchedUrl(props: any) {
const match = props.match;
return matchedUrlFromMatch(match);
}

View File

@@ -0,0 +1,310 @@
/*
* 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 { User, UserCollection } from "@scm-manager/ui-types";
import fetchMock from "fetch-mock-jest";
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
import { setIndexLink } from "./tests/indexLinks";
import { renderHook } from "@testing-library/react-hooks";
import createWrapper from "./tests/createWrapper";
import { act } from "react-test-renderer";
import {
useConvertToExternal,
useConvertToInternal,
useCreateUser,
useDeleteUser,
useUpdateUser,
useUser,
useUsers
} from "./users";
describe("Test user hooks", () => {
const yoda: User = {
active: false,
displayName: "",
external: false,
password: "",
name: "yoda",
_links: {
delete: {
href: "/users/yoda"
},
update: {
href: "/users/yoda"
},
convertToInternal: {
href: "/users/yoda/convertToInternal"
},
convertToExternal: {
href: "/users/yoda/convertToExternal"
}
},
_embedded: {
members: []
}
};
const userCollection: UserCollection = {
_links: {},
page: 0,
pageTotal: 0,
_embedded: {
users: [yoda]
}
};
afterEach(() => {
fetchMock.reset();
});
describe("useUsers tests", () => {
it("should return users", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.get("/api/v2/users", userCollection);
const { result, waitFor } = renderHook(() => useUsers(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(userCollection);
});
it("should return paged users", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.get("/api/v2/users", userCollection, {
query: {
page: "42"
}
});
const { result, waitFor } = renderHook(() => useUsers({ page: 42 }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(userCollection);
});
it("should return searched users", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.get("/api/v2/users", userCollection, {
query: {
q: "yoda"
}
});
const { result, waitFor } = renderHook(() => useUsers({ search: "yoda" }), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(userCollection);
});
it("should update user cache", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.get("/api/v2/users", userCollection);
const { result, waitFor } = renderHook(() => useUsers(), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(queryClient.getQueryData(["user", "yoda"])).toEqual(yoda);
});
});
describe("useUser tests", () => {
it("should return user", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.get("/api/v2/users/yoda", yoda);
const { result, waitFor } = renderHook(() => useUser("yoda"), {
wrapper: createWrapper(undefined, queryClient)
});
await waitFor(() => !!result.current.data);
expect(result.current.data).toEqual(yoda);
});
});
describe("useCreateUser tests", () => {
it("should create user", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.postOnce("/api/v2/users", {
status: 201,
headers: {
Location: "/users/yoda"
}
});
fetchMock.getOnce("/api/v2/users/yoda", yoda);
const { result, waitForNextUpdate } = renderHook(() => useCreateUser(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(yoda);
return waitForNextUpdate();
});
expect(result.current.user).toEqual(yoda);
});
it("should fail without location header", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.postOnce("/api/v2/users", {
status: 201
});
fetchMock.getOnce("/api/v2/users/yoda", yoda);
const { result, waitForNextUpdate } = renderHook(() => useCreateUser(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { create } = result.current;
create(yoda);
return waitForNextUpdate();
});
expect(result.current.error).toBeDefined();
});
});
describe("useDeleteUser tests", () => {
it("should delete user", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
fetchMock.deleteOnce("/api/v2/users/yoda", {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteUser(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { remove } = result.current;
remove(yoda);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isDeleted).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined();
});
});
describe("useUpdateUser tests", () => {
it("should update user", async () => {
const queryClient = createInfiniteCachingClient();
setIndexLink(queryClient, "users", "/users");
const newJedis = {
...yoda,
description: "may the 4th be with you"
};
fetchMock.putOnce("/api/v2/users/yoda", {
status: 200
});
fetchMock.getOnce("/api/v2/users/yoda", newJedis);
const { result, waitForNextUpdate } = renderHook(() => useUpdateUser(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { update } = result.current;
update(newJedis);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isUpdated).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined();
expect(queryClient.getQueryData(["users"])).toBeUndefined();
});
});
describe("useConvertToInternal tests", () => {
it("should convert user", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.putOnce("/api/v2/users/yoda/convertToInternal", {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useConvertToInternal(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { convertToInternal } = result.current;
convertToInternal(yoda, "thisisaverystrongpassword");
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isConverted).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined();
expect(queryClient.getQueryData(["users"])).toBeUndefined();
});
});
describe("useConvertToExternal tests", () => {
it("should convert user", async () => {
const queryClient = createInfiniteCachingClient();
fetchMock.putOnce("/api/v2/users/yoda/convertToExternal", {
status: 200
});
const { result, waitForNextUpdate } = renderHook(() => useConvertToExternal(), {
wrapper: createWrapper(undefined, queryClient)
});
await act(() => {
const { convertToExternal } = result.current;
convertToExternal(yoda);
return waitForNextUpdate();
});
expect(result.current.error).toBeFalsy();
expect(result.current.isConverted).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined();
expect(queryClient.getQueryData(["users"])).toBeUndefined();
});
});
});

198
scm-ui/ui-api/src/users.ts Normal file
View File

@@ -0,0 +1,198 @@
/*
* 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, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, User, UserCollection, UserCreation } from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { concat } from "./urls";
export type UseUsersRequest = {
page?: number | string;
search?: string;
};
export const useUsers = (request?: UseUsersRequest): ApiResult<UserCollection> => {
const queryClient = useQueryClient();
const indexLink = useRequiredIndexLink("users");
const queryParams: Record<string, string> = {};
if (request?.search) {
queryParams.q = request.search;
}
if (request?.page) {
queryParams.page = request.page.toString();
}
return useQuery<UserCollection, Error>(
["users", request?.search || "", request?.page || 0],
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()),
{
onSuccess: (users: UserCollection) => {
users._embedded.users.forEach((user: User) => queryClient.setQueryData(["user", user.name], user));
}
}
);
};
export const useUser = (name: string): ApiResult<User> => {
const indexLink = useRequiredIndexLink("users");
return useQuery<User, Error>(["user", name], () =>
apiClient.get(concat(indexLink, name)).then(response => response.json())
);
};
const createUser = (link: string) => {
return (user: UserCreation) => {
return apiClient
.post(link, user, "application/vnd.scmm-user+json;v=2")
.then(response => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
};
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
const link = useRequiredIndexLink("users");
const { mutate, data, isLoading, error } = useMutation<User, Error, UserCreation>(createUser(link), {
onSuccess: user => {
queryClient.setQueryData(["user", user.name], user);
return queryClient.invalidateQueries(["users"]);
}
});
return {
create: (user: UserCreation) => mutate(user),
isLoading,
error,
user: data
};
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
const updateUrl = (user._links.update as Link).href;
return apiClient.put(updateUrl, user, "application/vnd.scmm-user+json;v=2");
},
{
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
}
);
return {
update: (user: User) => mutate(user),
isLoading,
error,
isUpdated: !!data
};
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
const deleteUrl = (user._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, name) => {
await queryClient.invalidateQueries(["user", name]);
await queryClient.invalidateQueries(["users"]);
}
}
);
return {
remove: (user: User) => mutate(user),
isLoading,
error,
isDeleted: !!data
};
};
const convertToInternal = (url: string, newPassword: string) => {
return apiClient.put(
url,
{
newPassword
},
"application/vnd.scmm-user+json;v=2"
);
};
const convertToExternal = (url: string) => {
return apiClient.put(url, {}, "application/vnd.scmm-user+json;v=2");
};
export type ConvertToInternalRequest = {
user: User;
password: string;
};
export const useConvertToInternal = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, ConvertToInternalRequest>(
({ user, password }) => convertToInternal((user._links.convertToInternal as Link).href, password),
{
onSuccess: async (_, { user }) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
}
);
return {
convertToInternal: (user: User, password: string) => mutate({ user, password }),
isLoading,
error,
isConverted: !!data
};
};
export const useConvertToExternal = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => convertToExternal((user._links.convertToExternal as Link).href),
{
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
}
);
return {
convertToExternal: (user: User) => mutate(user),
isLoading,
error,
isConverted: !!data
};
};

View File

@@ -0,0 +1,29 @@
/*
* 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.
*/
export const createQueryString = (params: Record<string, string>) => {
return Object.keys(params)
.map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k]))
.join("&");
};

View File

@@ -0,0 +1,6 @@
{
"extends": "@scm-manager/tsconfig",
"exclude": [
"./scripts"
]
}

View File

@@ -0,0 +1,3 @@
{
"presets": ["@scm-manager/babel-preset"]
}

View File

@@ -25,12 +25,9 @@ import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { addDecorator, configure } from "@storybook/react";
import { withI18next } from "storybook-addon-i18next";
import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss";
import React from "react";
import { MemoryRouter } from "react-router-dom";
import withRedux from "./withRedux";
import withApiProvider from "./withApiProvider";
let i18n = i18next;
@@ -38,11 +35,10 @@ let i18n = i18next;
// and not for storyshots
if (!process.env.JEST_WORKER_ID) {
const Backend = require("i18next-fetch-backend");
i18n = i18n.use(Backend.default)
i18n = i18n.use(Backend.default);
}
i18n
.use(initReactI18next).init({
i18n.use(initReactI18next).init({
whitelist: ["en", "de", "es"],
lng: "en",
fallbackLng: "en",
@@ -71,6 +67,6 @@ addDecorator(
})
);
addDecorator(withRedux);
addDecorator(withApiProvider);
configure(require.context("../src", true, /\.stories\.tsx?$/), module);

View File

@@ -1,79 +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.
*/
const WorkerPlugin = require("worker-plugin");
module.exports = {
module: {
rules: [
{
parser: {
system: false,
systemjs: false
}
},
{
test: /\.(js|ts|jsx|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
cacheDirectory: true,
presets: ["@scm-manager/babel-preset"]
}
}
]
},
{
test: /\.(css|scss|sass)$/i,
use: [
// Creates `style` nodes from JS strings
"style-loader",
{
loader: "css-loader",
options: {
// Run `postcss-loader` on each CSS `@import`, do not forget that `sass-loader` compile non CSS `@import`'s into a single file
// If you need run `sass-loader` and `postcss-loader` on each CSS `@import` please set it to `2`
importLoaders: 1,
// Automatically enable css modules for files satisfying `/\.module\.\w+$/i` RegExp.
modules: { auto: true }
}
},
// Compiles Sass to CSS
"sass-loader"
]
},
{
test: /\.(png|svg|jpg|gif|woff2?|eot|ttf)$/,
use: ["file-loader"]
}
]
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"]
},
plugins: [
new WorkerPlugin()
]
};

View File

@@ -0,0 +1,46 @@
/*
* 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 * as React from "react";
import { ApiProvider } from "@scm-manager/ui-api";
const withApiProvider = (storyFn) => {
return React.createElement(ApiProvider, {
index: {
version: "x.y.z",
_links: {}
},
me: {
name: "trillian",
displayName: "Trillian McMillan",
mail: "trillian@hitchhiker.com",
groups: [],
_links: {}
},
children: storyFn()
});
}
export default withApiProvider;

View File

@@ -18,15 +18,15 @@
"update-storyshots": "jest --testPathPattern=\"storyshots.test.ts\" --collectCoverage=false -u"
},
"devDependencies": {
"@scm-manager/babel-preset": "^2.10.1",
"@scm-manager/babel-preset": "^2.11.2",
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.4",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/ui-tests": "^2.13.1-SNAPSHOT",
"@storybook/addon-actions": "^6.0.28",
"@storybook/addon-storyshots": "^6.0.28",
"@storybook/react": "^6.0.28",
"@storybook/addon-actions": "^6.1.17",
"@storybook/addon-storyshots": "^6.1.17",
"@storybook/react": "^6.1.17",
"@types/classnames": "^2.2.9",
"@types/css": "^0.0.31",
"@types/enzyme": "^3.10.3",
@@ -51,12 +51,12 @@
"react-test-renderer": "^16.10.2",
"storybook-addon-i18next": "^1.3.0",
"to-camel-case": "^1.0.0",
"typescript": "^3.7.2",
"worker-plugin": "^3.2.0"
},
"dependencies": {
"@scm-manager/ui-extensions": "^2.13.1-SNAPSHOT",
"@scm-manager/ui-types": "^2.13.1-SNAPSHOT",
"@scm-manager/ui-api": "^2.13.1-SNAPSHOT",
"classnames": "^2.2.6",
"date-fns": "^2.4.1",
"gitdiff-parser": "^0.1.2",

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React from "react";
import { BackendError } from "./errors";
import { BackendError } from "@scm-manager/ui-api";
import Notification from "./Notification";
import { WithTranslation, withTranslation } from "react-i18next";

View File

@@ -27,16 +27,16 @@ import { useHistory, useLocation, Link } from "react-router-dom";
import classNames from "classnames";
import styled from "styled-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Branch, Repository, File } from "@scm-manager/ui-types";
import Icon from "./Icon";
import Tooltip from "./Tooltip";
import copyToClipboard from "./CopyToClipboard";
import { withContextPath } from "./urls";
import { urls } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
branch: Branch;
defaultBranch: Branch;
branch?: Branch;
defaultBranch?: Branch;
revision: string;
path: string;
baseUrl: string;
@@ -53,6 +53,7 @@ const PermaLinkWrapper = styled.div`
color: #dbdbdb;
opacity: 0.75;
}
&:hover i {
color: #b5b5b5;
opacity: 1;
@@ -108,7 +109,7 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
}
return (
<li key={index}>
<Link to={baseUrl + "/" + revision + "/" + currPath}>{pathFragment}</Link>
<Link to={baseUrl + "/" + encodeURIComponent(revision) + "/" + currPath}>{pathFragment}</Link>
</li>
);
});
@@ -120,7 +121,7 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
history.push(location.pathname);
setCopying(true);
copyToClipboard(
window.location.protocol + "//" + window.location.host + withContextPath(permalink || location.pathname)
window.location.protocol + "//" + window.location.host + urls.withContextPath(permalink || location.pathname)
).finally(() => setCopying(false));
};
@@ -161,7 +162,7 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
branch: branch ? branch : defaultBranch,
path,
sources,
repository,
repository
}}
renderAll={true}
/>

View File

@@ -21,31 +21,97 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ComponentType, ReactNode } from "react";
import React, { FC, ReactNode, useEffect } from "react";
import ErrorNotification from "./ErrorNotification";
import { MissingLinkError } from "./errors";
import { withContextPath } from "./urls";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { MissingLinkError, urls, useIndexLink } from "@scm-manager/ui-api";
import { RouteComponentProps, useLocation, withRouter } from "react-router-dom";
import ErrorPage from "./ErrorPage";
import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { Subtitle, Title } from "./layout";
import Icon from "./Icon";
import styled from "styled-components";
type ExportedProps = {
fallback?: React.ComponentType<any>;
children: ReactNode;
loginLink?: string;
type State = {
error?: Error;
errorInfo?: ErrorInfo;
};
type Props = WithTranslation & RouteComponentProps & ExportedProps;
type ExportedProps = {
fallback?: React.ComponentType<State>;
children: ReactNode;
};
type Props = RouteComponentProps & ExportedProps;
type ErrorInfo = {
componentStack: string;
};
type State = {
error?: Error;
errorInfo?: ErrorInfo;
type ErrorDisplayProps = {
fallback?: React.ComponentType<State>;
error: Error;
errorInfo: ErrorInfo;
};
const RedirectIconContainer = styled.div`
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 256px;
`;
const RedirectPage = () => {
const [t] = useTranslation("commons");
// we use an icon instead of loading spinner,
// because a redirect is synchron and a spinner does not spin on a synchron action
return (
<section className="section">
<div className="container">
<Title>{t("errorBoundary.redirect.title")}</Title>
<Subtitle>{t("errorBoundary.redirect.subtitle")}</Subtitle>
<RedirectIconContainer className="is-flex">
<Icon name="directions" className="fa-7x" />
</RedirectIconContainer>
</div>
</section>
);
};
const ErrorDisplay: FC<ErrorDisplayProps> = ({ error, errorInfo, fallback: FallbackComponent }) => {
const loginLink = useIndexLink("login");
const [t] = useTranslation("commons");
const location = useLocation();
const isMissingLink = error instanceof MissingLinkError;
useEffect(() => {
if (isMissingLink && loginLink) {
window.location.assign(urls.withContextPath("/login?from=" + location.pathname));
}
}, [isMissingLink, loginLink, location.pathname]);
if (isMissingLink) {
if (loginLink) {
// we can render a loading screen,
// because the effect hook above should redirect
return <RedirectPage />;
} else {
// missing link error without login link means we have no permissions
// and we should render an error
return (
<ErrorPage error={error} title={t("errorNotification.prefix")} subtitle={t("errorNotification.forbidden")} />
);
}
}
if (!FallbackComponent) {
return <ErrorNotification error={error} />;
}
const fallbackProps = {
error,
errorInfo
};
return <FallbackComponent {...fallbackProps} />;
};
class ErrorBoundary extends React.Component<Props, State> {
@@ -62,62 +128,20 @@ class ErrorBoundary extends React.Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState(
{
error,
errorInfo
},
() => this.redirectToLogin(error)
);
this.setState({
error,
errorInfo
});
}
redirectToLogin = (error: Error) => {
const { loginLink, location } = this.props;
if (error instanceof MissingLinkError) {
if (loginLink) {
window.location.assign(withContextPath("/login?from=" + location.pathname));
}
}
};
renderError = () => {
const { t } = this.props;
const { error } = this.state;
let FallbackComponent = this.props.fallback;
if (error instanceof MissingLinkError) {
return (
<ErrorPage error={error} title={t("errorNotification.prefix")} subtitle={t("errorNotification.forbidden")} />
);
}
if (!FallbackComponent) {
FallbackComponent = ErrorNotification;
}
return <FallbackComponent {...this.state} />;
};
render() {
const { error } = this.state;
if (error) {
return this.renderError();
const { fallback } = this.props;
const { error, errorInfo } = this.state;
if (error && errorInfo) {
return <ErrorDisplay error={error} errorInfo={errorInfo} fallback={fallback} />;
}
return this.props.children;
}
}
const mapStateToProps = (state: any) => {
const loginLink = state.indexResources?.links?.login?.href;
return {
loginLink
};
};
export default compose<ComponentType<ExportedProps>>(
withRouter,
withTranslation("commons"),
connect(mapStateToProps)
)(ErrorBoundary);
export default withRouter(ErrorBoundary);

View File

@@ -22,15 +22,14 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
import { BackendError, ForbiddenError, UnauthorizedError } from "./errors";
import { useTranslation } from "react-i18next";
import { BackendError, ForbiddenError, UnauthorizedError, urls } from "@scm-manager/ui-api";
import Notification from "./Notification";
import BackendErrorNotification from "./BackendErrorNotification";
import { useLocation } from "react-router-dom";
import { withContextPath } from "./urls";
type Props = WithTranslation & {
error?: Error;
type Props = {
error?: Error | null;
};
const LoginLink: FC = () => {
@@ -38,37 +37,35 @@ const LoginLink: FC = () => {
const location = useLocation();
const from = encodeURIComponent(location.hash ? location.pathname + location.hash : location.pathname);
return <a href={withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
return <a href={urls.withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
};
class ErrorNotification extends React.Component<Props> {
render() {
const { t, error } = this.props;
if (error) {
if (error instanceof BackendError) {
return <BackendErrorNotification error={error} />;
} else if (error instanceof UnauthorizedError) {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")} <LoginLink />
</Notification>
);
} else if (error instanceof ForbiddenError) {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.forbidden")}
</Notification>
);
} else {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {error.message}
</Notification>
);
}
const ErrorNotification: FC<Props> = ({ error }) => {
const [t] = useTranslation("commons");
if (error) {
if (error instanceof BackendError) {
return <BackendErrorNotification error={error} />;
} else if (error instanceof UnauthorizedError) {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")} <LoginLink />
</Notification>
);
} else if (error instanceof ForbiddenError) {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.forbidden")}
</Notification>
);
} else {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {error.message}
</Notification>
);
}
return null;
}
}
return null;
};
export default withTranslation("commons")(ErrorNotification);
export default ErrorNotification;

View File

@@ -23,7 +23,7 @@
*/
import React from "react";
import ErrorNotification from "./ErrorNotification";
import { BackendError, ForbiddenError } from "./errors";
import { BackendError, ForbiddenError } from "@scm-manager/ui-api";
type Props = {
error: Error;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React from "react";
import { withContextPath } from "./urls";
import { urls } from "@scm-manager/ui-api";
type Props = {
src: string;
@@ -37,7 +37,7 @@ class Image extends React.Component<Props> {
if (src.startsWith("http")) {
return src;
}
return withContextPath(src);
return urls.withContextPath(src);
};
render() {

View File

@@ -25,17 +25,17 @@
import React, { FC } from "react";
import SyntaxHighlighter from "./SyntaxHighlighter";
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
import { connect } from "react-redux";
import { useIndexLinks } from "@scm-manager/ui-api";
type Props = {
language?: string;
value: string;
indexLinks: { [key: string]: any };
};
const MarkdownCodeRenderer: FC<Props> = (props) => {
const MarkdownCodeRenderer: FC<Props> = props => {
const binder = useBinder();
const { language, indexLinks } = props;
const indexLinks = useIndexLinks();
const { language } = props;
const extensionKey = `markdown-renderer.code.${language}`;
if (binder.hasExtension(extensionKey, props)) {
return <ExtensionPoint name={extensionKey} props={{ ...props, indexLinks }} />;
@@ -43,12 +43,4 @@ const MarkdownCodeRenderer: FC<Props> = (props) => {
return <SyntaxHighlighter {...props} />;
};
const mapStateToProps = (state: any) => {
const indexLinks = state.indexResources.links;
return {
indexLinks,
};
};
export default connect(mapStateToProps)(MarkdownCodeRenderer);
export default MarkdownCodeRenderer;

View File

@@ -23,7 +23,7 @@
*/
import React, { ReactNode } from "react";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { withContextPath } from "./urls";
import { urls } from "@scm-manager/ui-api";
/**
* Adds anchor links to markdown headings.
@@ -54,7 +54,7 @@ function MarkdownHeadingRenderer(props: Props) {
const heading = children.reduce(flatten, "");
const anchorId = headingToAnchorId(heading);
const headingElement = React.createElement("h" + props.level, {}, props.children);
const href = withContextPath(props.location.pathname + "#" + anchorId);
const href = urls.withContextPath(props.location.pathname + "#" + anchorId);
return (
<a id={`${anchorId}`} className="anchor" href={href}>

View File

@@ -24,7 +24,7 @@
import React, { FC } from "react";
import { Link, useLocation } from "react-router-dom";
import ExternalLink from "./navigation/ExternalLink";
import { withContextPath } from "./urls";
import { urls } from "@scm-manager/ui-api";
const externalLinkRegex = new RegExp("^http(s)?://");
export const isExternalLink = (link: string) => {
@@ -116,7 +116,7 @@ const MarkdownLinkRenderer: FC<Props> = ({ href, base, children }) => {
} else if (isLinkWithProtocol(href)) {
return <a href={href}>{children}</a>;
} else if (isAnchorLink(href)) {
return <a href={withContextPath(location.pathname) + href}>{children}</a>;
return <a href={urls.withContextPath(location.pathname) + href}>{children}</a>;
} else {
const localLink = createLocalLink(base, location.pathname, href);
return <Link to={localLink}>{children}</Link>;

View File

@@ -30,7 +30,7 @@ import { FilterInput } from "./forms";
type Props = {
showCreateButton: boolean;
currentGroup: string;
groups: string[];
groups?: string[];
link: string;
groupSelected: (namespace: string) => void;
label?: string;
@@ -51,6 +51,7 @@ const OverviewPageActions: FC<Props> = ({
const history = useHistory();
const location = useLocation();
const [filterValue, setFilterValue] = useState(urls.getQueryStringFromLocation(location));
const groupSelector = groups && (
<div className={"column is-flex"}>
<DropDown

View File

@@ -28,7 +28,7 @@ import { defaultLanguage, determineLanguage } from "./languages";
// eslint-disable-next-line no-restricted-imports
import highlightingTheme from "./syntax-highlighting";
import { useLocation } from "react-router-dom";
import { withContextPath } from "./urls";
import { urls } from "@scm-manager/ui-api";
import createSyntaxHighlighterRenderer from "./SyntaxHighlighterRenderer";
import useScrollToElement from "./useScrollToElement";
@@ -60,7 +60,7 @@ const SyntaxHighlighter: FC<Props> = ({ language = defaultLanguage, showLineNumb
window.location.protocol +
"//" +
window.location.host +
withContextPath((permalink || location.pathname) + "#line-" + lineNumber);
urls.withContextPath((permalink || location.pathname) + "#line-" + lineNumber);
const defaultRenderer = createSyntaxHighlighterRenderer(createLinePermaLink, showLineNumbers);

View File

@@ -24,7 +24,7 @@
import React from "react";
import { SelectValue, AutocompleteObject } from "@scm-manager/ui-types";
import Autocomplete from "./Autocomplete";
import { apiClient } from "./apiclient";
import { apiClient } from "@scm-manager/ui-api";
export type AutocompleteProps = {
autocompleteLink?: string;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { Changeset, PagedCollection } from "@scm-manager/ui-types";
import {Changeset, ChangesetCollection, PagedCollection} from "@scm-manager/ui-types";
const one: Changeset = {
id: "a88567ef1e9528a700555cad8c4576b72fc7c6dd",
@@ -266,7 +266,7 @@ const five: Changeset = {
}
};
const changesets: PagedCollection = {
const changesets: ChangesetCollection = {
page: 0,
pageTotal: 1,
_links: {

File diff suppressed because it is too large Load Diff

View File

@@ -22,12 +22,11 @@
* SOFTWARE.
*/
// @create-index
import * as validation from "./validation";
import * as urls from "./urls";
import * as repositories from "./repositories";
import { urls } from "@scm-manager/ui-api";
// not sure if it is required
import {
File,
@@ -41,7 +40,7 @@ import {
DiffEventContext
} from "./repos";
export { validation, urls, repositories };
export { validation, repositories };
export { default as DateFromNow } from "./DateFromNow";
export { default as DateShort } from "./DateShort";
@@ -63,8 +62,6 @@ export { default as Help } from "./Help";
export { default as HelpIcon } from "./HelpIcon";
export { default as Tag } from "./Tag";
export { default as Tooltip } from "./Tooltip";
// TODO do we need this? getPageFromMatch is already exported by urls
export { getPageFromMatch } from "./urls";
export { default as Autocomplete } from "./Autocomplete";
export { default as GroupAutocomplete } from "./GroupAutocomplete";
export { default as UserAutocomplete } from "./UserAutocomplete";
@@ -84,8 +81,6 @@ export { regExpPattern as changesetShortLinkRegex } from "./remarkChangesetShort
export { default as comparators } from "./comparators";
export { apiClient } from "./apiclient";
export * from "./errors";
export { isDevBuild, createAttributesForTesting } from "./devBuild";
export * from "./avatar";
@@ -111,3 +106,23 @@ export {
DiffEventHandler,
DiffEventContext
};
// Re-export from ui-api
export { apiClient } from "@scm-manager/ui-api";
export {
Violation,
AdditionalMessage,
BackendErrorContent,
BackendError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
MissingLinkError,
createBackendError,
isBackendError,
TOKEN_EXPIRED_ERROR_CODE
} from "@scm-manager/ui-api";
export { urls };
export const getPageFromMatch = urls.getPageFromMatch;

View File

@@ -31,9 +31,11 @@ type Props = {
class Subtitle extends React.Component<Props> {
render() {
const { subtitle, className } = this.props;
const { subtitle, className, children } = this.props;
if (subtitle) {
return <h2 className={classNames("subtitle", className)}>{subtitle}</h2>;
} else if (children) {
return <h2 className={classNames("subtitle", className)}>{children}</h2>;
}
return null;
}

View File

@@ -30,6 +30,7 @@ import classNames from "classnames";
type Button = {
className?: string;
label: string;
isLoading?: boolean;
onClick?: () => void | null;
};
@@ -65,7 +66,7 @@ export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
{buttons.map((button, index) => (
<p className="control" key={index}>
<a
className={classNames("button", "is-info", button.className)}
className={classNames("button", "is-info", button.className, button.isLoading ? "is-loading" : "")}
key={index}
onClick={() => handleClickButton(button)}
>

View File

@@ -37,6 +37,9 @@ type Props = WithTranslation & {
revision: string;
};
/**
* @deprecated
*/
const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => {
const [newTagName, setNewTagName] = useState("");
const [loading, setLoading] = useState(false);

View File

@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink";
import { Links } from "@scm-manager/ui-types";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import {withContextPath} from "../urls";
import { urls } from "@scm-manager/ui-api";
import { withRouter, RouteComponentProps } from "react-router-dom";
type Props = RouteComponentProps & WithTranslation & {
@@ -75,7 +75,7 @@ class PrimaryNavigation extends React.Component<Props> {
const props = {
links,
label: t("primary-navigation.login"),
loginUrl: withContextPath(loginPath),
loginUrl: urls.withContextPath(loginPath),
from
};

View File

@@ -25,16 +25,15 @@
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import Notification from "../Notification";
import { Me } from "@scm-manager/ui-types";
import { connect } from "react-redux";
import { useMe } from "@scm-manager/ui-api";
type Props = {
// props from global state
me: Me;
};
const CommitAuthor: FC<Props> = ({ me }) => {
const CommitAuthor: FC = () => {
const [t] = useTranslation("repos");
const { data: me } = useMe();
if (!me) {
return null;
}
const mail = me.mail ? me.mail : me.fallbackMail;
@@ -48,13 +47,4 @@ const CommitAuthor: FC<Props> = ({ me }) => {
);
};
const mapStateToProps = (state: any) => {
const { auth } = state;
const me = auth.me;
return {
me
};
};
export default connect(mapStateToProps)(CommitAuthor);
export default CommitAuthor;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React from "react";
import { apiClient } from "../apiclient";
import { apiClient, NotFoundError } from "@scm-manager/ui-api";
import ErrorNotification from "../ErrorNotification";
// @ts-ignore
import parser from "gitdiff-parser";
@@ -30,7 +30,6 @@ import parser from "gitdiff-parser";
import Loading from "../Loading";
import Diff from "./Diff";
import { DiffObjectProps, File } from "./DiffTypes";
import { NotFoundError } from "../errors";
import { Notification } from "../index";
import { withTranslation, WithTranslation } from "react-i18next";

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React from "react";
import { Changeset, Collection, Link } from "@scm-manager/ui-types";
import { Changeset, HalRepresentation, Link } from "@scm-manager/ui-types";
import LoadingDiff from "../LoadingDiff";
import Notification from "../../Notification";
import { WithTranslation, withTranslation } from "react-i18next";
@@ -34,11 +34,11 @@ type Props = WithTranslation & {
fileControlFactory?: FileControlFactory;
};
export const isDiffSupported = (changeset: Collection) => {
export const isDiffSupported = (changeset: HalRepresentation) => {
return !!changeset._links.diff || !!changeset._links.diffParsed;
};
export const createUrl = (changeset: Collection) => {
export const createUrl = (changeset: HalRepresentation) => {
if (changeset._links.diffParsed) {
return (changeset._links.diffParsed as Link).href;
} else if (changeset._links.diff) {

View File

@@ -13,15 +13,14 @@
"react": "^16.10.2"
},
"devDependencies": {
"@scm-manager/babel-preset": "^2.10.1",
"@scm-manager/babel-preset": "^2.11.2",
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.4",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@types/enzyme": "^3.10.3",
"@types/jest": "^24.0.19",
"@types/react": "^16.9.9",
"typescript": "^3.7.2"
"@types/react": "^16.9.9"
},
"babel": {
"presets": [

View File

@@ -18,11 +18,11 @@
"styled-components": "^5.1.0"
},
"devDependencies": {
"@scm-manager/babel-preset": "^2.10.1",
"@scm-manager/babel-preset": "^2.11.2",
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.4",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/ui-scripts": "^2.13.1-SNAPSHOT",
"@scm-manager/ui-tests": "^2.13.1-SNAPSHOT",
"@scm-manager/ui-types": "^2.13.1-SNAPSHOT",

View File

@@ -23,9 +23,9 @@
"access": "public"
},
"devDependencies": {
"@scm-manager/tsconfig": "^2.11.2",
"@types/enzyme": "^3.10.3",
"@types/enzyme-adapter-react-16": "^1.0.5",
"@types/jest": "^24.0.19",
"typescript": "^3.7.2"
"@types/jest": "^24.0.19"
}
}

View File

@@ -14,7 +14,7 @@
"typecheck": "tsc"
},
"devDependencies": {
"typescript": "^3.7.2"
"@scm-manager/tsconfig": "^2.11.2"
},
"babel": {
"presets": [

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
export const PENDING_SUFFIX = "PENDING";
export const SUCCESS_SUFFIX = "SUCCESS";
export const FAILURE_SUFFIX = "FAILURE";
export const RESET_SUFFIX = "RESET";
export type UpdateInfo = {
latestVersion: string;
link: string;
};

View File

@@ -22,7 +22,13 @@
* SOFTWARE.
*/
import { Links } from "./hal";
import { Embedded, HalRepresentationWithEmbedded, Links } from "./hal";
type EmbeddedBranches = {
branches: Branch[];
} & Embedded;
export type BranchCollection = HalRepresentationWithEmbedded<EmbeddedBranches>;
export type Branch = {
name: string;
@@ -33,7 +39,10 @@ export type Branch = {
_links: Links;
};
export type BranchRequest = {
export type BranchCreation = {
name: string;
parent: string;
};
// @deprecated use BranchCreation instead
export type BranchRequest = BranchCreation;

View File

@@ -22,25 +22,25 @@
* SOFTWARE.
*/
import { Collection, Links } from "./hal";
import { Embedded, HalRepresentationWithEmbedded, Links, PagedCollection } from "./hal";
import { Tag } from "./Tags";
import { Branch } from "./Branches";
import { Person } from "./Person";
import { Signature } from "./Signature";
export type Changeset = Collection & {
type ChangesetEmbedded = {
tags?: Tag[];
branches?: Branch[];
parents?: ParentChangeset[];
} & Embedded;
export type Changeset = HalRepresentationWithEmbedded<ChangesetEmbedded> & {
id: string;
date: Date;
author: Person;
description: string;
contributors?: Contributor[];
signatures?: Signature[];
_links: Links;
_embedded: {
tags?: Tag[];
branches?: Branch[];
parents?: ParentChangeset[];
};
};
export type Contributor = {
@@ -52,3 +52,9 @@ export type ParentChangeset = {
id: string;
_links: Links;
};
type EmbeddedChangesets = {
changesets: Changeset[];
} & Embedded;
export type ChangesetCollection = PagedCollection<EmbeddedChangesets>;

View File

@@ -22,11 +22,11 @@
* SOFTWARE.
*/
import { Links } from "./hal";
import { HalRepresentation } from "./hal";
export type AnonymousMode = "FULL" | "PROTOCOL_ONLY" | "OFF";
export type Config = {
export type Config = HalRepresentation & {
proxyPassword: string | null;
proxyPort: number;
proxyServer: string;
@@ -50,5 +50,4 @@ export type Config = {
loginInfoUrl: string;
releaseFeedUrl: string;
mailDomainName: string;
_links: Links;
};

View File

@@ -22,22 +22,34 @@
* SOFTWARE.
*/
import { Collection, Links } from "./hal";
import { Collection, Links, PagedCollection } from "./hal";
export type Member = {
name: string;
_links: Links;
};
export type Group = Collection & {
export type GroupBase = {
name: string;
description: string;
type: string;
external: boolean;
members: string[];
_embedded: {
members: Member[];
};
creationDate?: string;
lastModified?: string;
};
export type Group = Collection &
GroupBase & {
creationDate?: string;
lastModified?: string;
_embedded: {
members: Member[];
};
};
export type GroupCreation = GroupBase;
export type GroupCollection = PagedCollection & {
_embedded: {
groups: Group[];
};
};

View File

@@ -0,0 +1,35 @@
/*
* 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 { HalRepresentation } from "./hal";
export type LoginInfo = HalRepresentation & {
plugin?: InfoItem;
feature?: InfoItem;
};
export type InfoItem = HalRepresentation & {
title: string;
summary: string;
};

View File

@@ -22,13 +22,12 @@
* SOFTWARE.
*/
import { Links } from "./hal";
import { HalRepresentation } from "./hal";
export type Me = {
export type Me = HalRepresentation & {
name: string;
displayName: string;
mail?: string;
fallbackMail?: string;
groups: string[];
_links: Links;
};

View File

@@ -24,6 +24,8 @@
import { Links } from "./hal";
export const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy";
export type NamespaceStrategies = {
current: string;
available: string[];

View File

@@ -22,9 +22,9 @@
* SOFTWARE.
*/
import { Collection, Links } from "./hal";
import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
export type Plugin = {
export type Plugin = HalRepresentation & {
name: string;
version: string;
newVersion?: string;
@@ -32,31 +32,26 @@ export type Plugin = {
description?: string;
author: string;
category: string;
avatarUrl: string;
avatarUrl?: string;
pending: boolean;
markedForUninstall?: boolean;
dependencies: string[];
optionalDependencies: string[];
_links: Links;
};
export type PluginCollection = Collection & {
_links: Links;
_embedded: {
plugins: Plugin[] | string[];
};
};
export type PluginCollection = HalRepresentationWithEmbedded<{
plugins: Plugin[];
}>;
export const isPluginCollection = (input: HalRepresentation): input is PluginCollection => input._embedded ? "plugins" in input._embedded : false;
export type PluginGroup = {
name: string;
plugins: Plugin[];
};
export type PendingPlugins = {
_links: Links;
_embedded: {
new: [];
update: [];
uninstall: [];
};
};
export type PendingPlugins = HalRepresentationWithEmbedded<{
new: Plugin[];
update: Plugin[];
uninstall: Plugin[];
}>;

View File

@@ -22,22 +22,28 @@
* SOFTWARE.
*/
import { PagedCollection, Links } from "./hal";
import { PagedCollection, Links, HalRepresentation } from "./hal";
export type Repository = {
export type NamespaceAndName = {
namespace: string;
name: string;
};
export type RepositoryBase = NamespaceAndName & {
type: string;
contact?: string;
description?: string;
creationDate?: string;
lastModified?: string;
archived?: boolean;
exporting?: boolean;
_links: Links;
};
}
export type RepositoryCreation = Repository & {
export type Repository = HalRepresentation &
RepositoryBase & {
creationDate?: string;
lastModified?: string;
archived?: boolean;
exporting?: boolean;
};
export type RepositoryCreation = RepositoryBase & {
contextEntries: { [key: string]: any };
};
@@ -52,12 +58,12 @@ export type Namespace = {
_links: Links;
};
export type RepositoryCollection = PagedCollection & {
_embedded: {
repositories: Repository[] | string[];
};
type RepositoryEmbedded = {
repositories: Repository[];
};
export type RepositoryCollection = PagedCollection<RepositoryEmbedded>;
export type NamespaceCollection = {
_embedded: {
namespaces: Namespace[];

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { Links } from "./hal";
import { HalRepresentationWithEmbedded, Links } from "./hal";
export type PermissionCreateEntry = {
name: string;
@@ -35,4 +35,10 @@ export type Permission = PermissionCreateEntry & {
_links: Links;
};
export type PermissionCollection = Permission[];
type PermissionEmbedded = {
permissions: Permission[];
};
// TODO fix wrong usage of PermissionCollection
export type PermissionCollection = HalRepresentationWithEmbedded<PermissionEmbedded>;

View File

@@ -22,13 +22,22 @@
* SOFTWARE.
*/
import { Links } from "./hal";
import {HalRepresentation, PagedCollection} from "./hal";
export type RepositoryRole = {
export type RepositoryRoleBase = {
name: string;
verbs: string[];
type?: string;
};
export type RepositoryRole = HalRepresentation & RepositoryRoleBase & {
creationDate?: string;
lastModified?: string;
_links: Links;
};
export type RepositoryRoleCreation = RepositoryRoleBase;
type RepositoryRoleEmbedded = {
repositoryRoles: RepositoryRole[];
};
export type RepositoryRoleCollection = PagedCollection<RepositoryRoleEmbedded>;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { Collection, Links } from "./hal";
import { HalRepresentationWithEmbedded, Links } from "./hal";
export type RepositoryType = {
name: string;
@@ -30,8 +30,8 @@ export type RepositoryType = {
_links: Links;
};
export type RepositoryTypeCollection = Collection & {
_embedded: {
repositoryTypes: RepositoryType[];
};
type RepositoryTypeEmbedded = {
repositoryTypes: RepositoryType[];
};
export type RepositoryTypeCollection = HalRepresentationWithEmbedded<RepositoryTypeEmbedded>;

Some files were not shown because too many files have changed in this diff Show More