mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-11 07:55:47 +01:00
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:
committed by
GitHub
parent
ad5c8102c0
commit
3a8d031ed5
60
scm-ui/ui-api/src/ApiProvider.test.tsx
Normal file
60
scm-ui/ui-api/src/ApiProvider.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
81
scm-ui/ui-api/src/ApiProvider.tsx
Normal file
81
scm-ui/ui-api/src/ApiProvider.tsx
Normal 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;
|
||||
46
scm-ui/ui-api/src/LegacyContext.test.tsx
Normal file
46
scm-ui/ui-api/src/LegacyContext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
45
scm-ui/ui-api/src/LegacyContext.tsx
Normal file
45
scm-ui/ui-api/src/LegacyContext.tsx
Normal 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 { IndexResources, Me } from "@scm-manager/ui-types";
|
||||
import React, { createContext, FC, useContext } from "react";
|
||||
|
||||
export type LegacyContext = {
|
||||
onIndexFetched?: (index: IndexResources) => void;
|
||||
onMeFetched?: (me: Me) => void;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
55
scm-ui/ui-api/src/admin.test.ts
Normal file
55
scm-ui/ui-api/src/admin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
scm-ui/ui-api/src/admin.ts
Normal file
35
scm-ui/ui-api/src/admin.ts
Normal 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 { ApiResult, useRequiredIndexLink } from "./base";
|
||||
import { UpdateInfo } from "@scm-manager/ui-types";
|
||||
import { useQuery } from "react-query";
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
|
||||
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()))
|
||||
);
|
||||
};
|
||||
115
scm-ui/ui-api/src/apiclient.test.ts
Normal file
115
scm-ui/ui-api/src/apiclient.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { apiClient, createUrl, extractXsrfTokenFromCookie } from "./apiclient";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { BackendError } from "./errors";
|
||||
|
||||
describe("create url", () => {
|
||||
it("should not change absolute urls", () => {
|
||||
expect(createUrl("https://www.scm-manager.org")).toBe("https://www.scm-manager.org");
|
||||
});
|
||||
|
||||
it("should add prefix for api", () => {
|
||||
expect(createUrl("/users")).toBe("/api/v2/users");
|
||||
expect(createUrl("users")).toBe("/api/v2/users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling tests", () => {
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [
|
||||
{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should create a normal error, if the content type is not scmm-error", done => {
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404
|
||||
});
|
||||
|
||||
apiClient.get("/error").catch((err: Error) => {
|
||||
expect(err.name).toEqual("Error");
|
||||
expect(err.message).toContain("404");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an backend error, if the content type is scmm-error", done => {
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.scmm-error+json;v=2"
|
||||
},
|
||||
body: earthNotFoundError
|
||||
});
|
||||
|
||||
apiClient.get("/error").catch((err: BackendError) => {
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
|
||||
expect(err.message).toEqual("earth not found");
|
||||
expect(err.statusCode).toBe(404);
|
||||
|
||||
expect(err.transactionId).toEqual("42t");
|
||||
expect(err.errorCode).toEqual("42e");
|
||||
expect(err.context).toEqual([
|
||||
{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}
|
||||
]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extract xsrf token", () => {
|
||||
it("should return undefined if no cookie exists", () => {
|
||||
const token = extractXsrfTokenFromCookie(undefined);
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined without X-Bearer-Token exists", () => {
|
||||
const token = extractXsrfTokenFromCookie("a=b; c=d; e=f");
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return xsrf token", () => {
|
||||
const cookie =
|
||||
"a=b; X-Bearer-Token=eyJhbGciOiJIUzI1NiJ9.eyJ4c3JmIjoiYjE0NDRmNWEtOWI5Mi00ZDA0LWFkMzMtMTAxYjY3MWQ1YTc0Iiwic3ViIjoic2NtYWRtaW4iLCJqdGkiOiI2RFJpQVphNWwxIiwiaWF0IjoxNTc0MDcyNDQ4LCJleHAiOjE1NzQwNzYwNDgsInNjbS1tYW5hZ2VyLnJlZnJlc2hFeHBpcmF0aW9uIjoxNTc0MTE1NjQ4OTU5LCJzY20tbWFuYWdlci5wYXJlbnRUb2tlbklkIjoiNkRSaUFaYTVsMSJ9.VUJtKeWUn3xtHCEbG51r7ceXZ8CF3cmN8J-eb9EDY_U; c=d";
|
||||
const token = extractXsrfTokenFromCookie(cookie);
|
||||
expect(token).toBe("b1444f5a-9b92-4d04-ad33-101b671d5a74");
|
||||
});
|
||||
});
|
||||
316
scm-ui/ui-api/src/apiclient.ts
Normal file
316
scm-ui/ui-api/src/apiclient.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* 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 { contextPath } from "./urls";
|
||||
import { BackendErrorContent, createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors";
|
||||
|
||||
type SubscriptionEvent = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type OpenEvent = SubscriptionEvent;
|
||||
|
||||
type ErrorEvent = SubscriptionEvent & {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type MessageEvent = SubscriptionEvent & {
|
||||
data: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
type MessageListeners = {
|
||||
[eventType: string]: (event: MessageEvent) => void;
|
||||
};
|
||||
|
||||
type SubscriptionContext = {
|
||||
onOpen?: OpenEvent;
|
||||
onMessage: MessageListeners;
|
||||
onError?: ErrorEvent;
|
||||
};
|
||||
|
||||
type SubscriptionArgument = MessageListeners | SubscriptionContext;
|
||||
|
||||
type Cancel = () => void;
|
||||
|
||||
const sessionId = (
|
||||
Date.now().toString(36) +
|
||||
Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 5)
|
||||
).toUpperCase();
|
||||
|
||||
const extractXsrfTokenFromJwt = (jwt: string) => {
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length === 3) {
|
||||
return JSON.parse(atob(parts[1])).xsrf;
|
||||
}
|
||||
};
|
||||
|
||||
// @VisibleForTesting
|
||||
export const extractXsrfTokenFromCookie = (cookieString?: string) => {
|
||||
if (cookieString) {
|
||||
const cookies = cookieString.split(";");
|
||||
for (const c of cookies) {
|
||||
const parts = c.trim().split("=");
|
||||
if (parts[0] === "X-Bearer-Token") {
|
||||
return extractXsrfTokenFromJwt(parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extractXsrfToken = () => {
|
||||
return extractXsrfTokenFromCookie(document.cookie);
|
||||
};
|
||||
|
||||
const createRequestHeaders = () => {
|
||||
const headers: { [key: string]: string } = {
|
||||
// disable caching for now
|
||||
Cache: "no-cache",
|
||||
// identify the request as ajax request
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
// identify the web interface
|
||||
"X-SCM-Client": "WUI",
|
||||
// identify the window session
|
||||
"X-SCM-Session-ID": sessionId
|
||||
};
|
||||
|
||||
const xsrf = extractXsrfToken();
|
||||
if (xsrf) {
|
||||
headers["X-XSRF-Token"] = xsrf;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
|
||||
if (o.headers) {
|
||||
o.headers = {
|
||||
...createRequestHeaders()
|
||||
};
|
||||
} else {
|
||||
o.headers = createRequestHeaders();
|
||||
}
|
||||
o.credentials = "same-origin";
|
||||
return o;
|
||||
};
|
||||
|
||||
function handleFailure(response: Response) {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new UnauthorizedError("Unauthorized", 401);
|
||||
} else if (response.status === 403) {
|
||||
throw new ForbiddenError("Forbidden", 403);
|
||||
} else if (isBackendError(response)) {
|
||||
return response.json().then((content: BackendErrorContent) => {
|
||||
throw createBackendError(content, response.status);
|
||||
});
|
||||
} else {
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function createUrl(url: string) {
|
||||
if (url.includes("://")) {
|
||||
return url;
|
||||
}
|
||||
let urlWithStartingSlash = url;
|
||||
if (url.indexOf("/") !== 0) {
|
||||
urlWithStartingSlash = "/" + urlWithStartingSlash;
|
||||
}
|
||||
return `${contextPath}/api/v2${urlWithStartingSlash}`;
|
||||
}
|
||||
|
||||
export function createUrlWithIdentifiers(url: string): string {
|
||||
return createUrl(url) + "?X-SCM-Client=WUI&X-SCM-Session-ID=" + sessionId;
|
||||
}
|
||||
|
||||
type ErrorListener = (error: Error) => void;
|
||||
|
||||
type RequestListener = (url: string, options?: RequestInit) => void;
|
||||
|
||||
class ApiClient {
|
||||
errorListeners: ErrorListener[] = [];
|
||||
requestListeners: RequestListener[] = [];
|
||||
|
||||
get = (url: string): Promise<Response> => {
|
||||
return this.request(url, applyFetchOptions({}))
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
post = (
|
||||
url: string,
|
||||
payload?: any,
|
||||
contentType = "application/json",
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
) => {
|
||||
return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload);
|
||||
};
|
||||
|
||||
postText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
|
||||
return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload);
|
||||
};
|
||||
|
||||
putText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
|
||||
return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload);
|
||||
};
|
||||
|
||||
postBinary = (url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) => {
|
||||
const formData = new FormData();
|
||||
fileAppender(formData);
|
||||
|
||||
const options: RequestInit = {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: additionalHeaders
|
||||
};
|
||||
return this.httpRequestWithBinaryBody(options, url);
|
||||
};
|
||||
|
||||
put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
|
||||
return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload);
|
||||
}
|
||||
|
||||
head = (url: string) => {
|
||||
let options: RequestInit = {
|
||||
method: "HEAD"
|
||||
};
|
||||
options = applyFetchOptions(options);
|
||||
return this.request(url, options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
delete = (url: string): Promise<Response> => {
|
||||
let options: RequestInit = {
|
||||
method: "DELETE"
|
||||
};
|
||||
options = applyFetchOptions(options);
|
||||
return this.request(url, options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
httpRequestWithJSONBody = (
|
||||
method: string,
|
||||
url: string,
|
||||
contentType: string,
|
||||
additionalHeaders: Record<string, string>,
|
||||
payload?: any
|
||||
): Promise<Response> => {
|
||||
const options: RequestInit = {
|
||||
method: method,
|
||||
headers: additionalHeaders
|
||||
};
|
||||
if (payload) {
|
||||
options.body = JSON.stringify(payload);
|
||||
}
|
||||
return this.httpRequestWithBinaryBody(options, url, contentType);
|
||||
};
|
||||
|
||||
httpRequestWithTextBody = (
|
||||
method: string,
|
||||
url: string,
|
||||
additionalHeaders: Record<string, string> = {},
|
||||
payload: string
|
||||
) => {
|
||||
const options: RequestInit = {
|
||||
method: method,
|
||||
headers: additionalHeaders
|
||||
};
|
||||
options.body = payload;
|
||||
return this.httpRequestWithBinaryBody(options, url, "text/plain");
|
||||
};
|
||||
|
||||
httpRequestWithBinaryBody = (options: RequestInit, url: string, contentType?: string) => {
|
||||
options = applyFetchOptions(options);
|
||||
if (contentType) {
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
// @ts-ignore We are sure that here we only get headers of type {[name:string]: string}
|
||||
options.headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return this.request(url, options)
|
||||
.then(handleFailure)
|
||||
.catch(this.notifyAndRethrow);
|
||||
};
|
||||
|
||||
subscribe(url: string, argument: SubscriptionArgument): Cancel {
|
||||
const es = new EventSource(createUrlWithIdentifiers(url), {
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
let listeners: MessageListeners;
|
||||
// type guard, to identify that argument is of type SubscriptionContext
|
||||
if ("onMessage" in argument) {
|
||||
listeners = (argument as SubscriptionContext).onMessage;
|
||||
if (argument.onError) {
|
||||
// @ts-ignore typing of EventSource is weird
|
||||
es.onerror = argument.onError;
|
||||
}
|
||||
if (argument.onOpen) {
|
||||
// @ts-ignore typing of EventSource is weird
|
||||
es.onopen = argument.onOpen;
|
||||
}
|
||||
} else {
|
||||
listeners = argument;
|
||||
}
|
||||
|
||||
for (const type in listeners) {
|
||||
// @ts-ignore typing of EventSource is weird
|
||||
es.addEventListener(type, listeners[type]);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
237
scm-ui/ui-api/src/base.test.ts
Normal file
237
scm-ui/ui-api/src/base.test.ts
Normal 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
100
scm-ui/ui-api/src/base.ts
Normal 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
|
||||
});
|
||||
};
|
||||
219
scm-ui/ui-api/src/branches.test.ts
Normal file
219
scm-ui/ui-api/src/branches.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
scm-ui/ui-api/src/branches.ts
Normal file
104
scm-ui/ui-api/src/branches.ts
Normal 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
|
||||
};
|
||||
};
|
||||
179
scm-ui/ui-api/src/changesets.test.ts
Normal file
179
scm-ui/ui-api/src/changesets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
77
scm-ui/ui-api/src/changesets.ts
Normal file
77
scm-ui/ui-api/src/changesets.ts
Normal 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())
|
||||
);
|
||||
};
|
||||
113
scm-ui/ui-api/src/config.test.ts
Normal file
113
scm-ui/ui-api/src/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
56
scm-ui/ui-api/src/config.ts
Normal file
56
scm-ui/ui-api/src/config.ts
Normal 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
|
||||
};
|
||||
};
|
||||
59
scm-ui/ui-api/src/errors.test.ts
Normal file
59
scm-ui/ui-api/src/errors.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { BackendError, UnauthorizedError, createBackendError, NotFoundError } from "./errors";
|
||||
|
||||
describe("test createBackendError", () => {
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [
|
||||
{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}
|
||||
],
|
||||
violations: []
|
||||
};
|
||||
|
||||
it("should return a default backend error", () => {
|
||||
const err = createBackendError(earthNotFoundError, 500);
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
expect(err.name).toBe("BackendError");
|
||||
});
|
||||
|
||||
// 403 is no backend error
|
||||
xit("should return an unauthorized error for status code 403", () => {
|
||||
const err = createBackendError(earthNotFoundError, 403);
|
||||
expect(err).toBeInstanceOf(UnauthorizedError);
|
||||
expect(err.name).toBe("UnauthorizedError");
|
||||
});
|
||||
|
||||
it("should return an not found error for status code 404", () => {
|
||||
const err = createBackendError(earthNotFoundError, 404);
|
||||
expect(err).toBeInstanceOf(NotFoundError);
|
||||
expect(err.name).toBe("NotFoundError");
|
||||
});
|
||||
});
|
||||
120
scm-ui/ui-api/src/errors.ts
Normal file
120
scm-ui/ui-api/src/errors.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
type Context = {
|
||||
type: string;
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
export type Violation = {
|
||||
path?: string;
|
||||
message: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type AdditionalMessage = {
|
||||
key?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type BackendErrorContent = {
|
||||
transactionId: string;
|
||||
errorCode: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
context: Context;
|
||||
violations: Violation[];
|
||||
additionalMessages?: AdditionalMessage[];
|
||||
};
|
||||
|
||||
export class BackendError extends Error {
|
||||
transactionId: string;
|
||||
errorCode: string;
|
||||
url: string | null | undefined;
|
||||
context: Context = [];
|
||||
statusCode: number;
|
||||
violations: Violation[];
|
||||
additionalMessages?: AdditionalMessage[];
|
||||
|
||||
constructor(content: BackendErrorContent, name: string, statusCode: number) {
|
||||
super(content.message);
|
||||
this.name = name;
|
||||
this.transactionId = content.transactionId;
|
||||
this.errorCode = content.errorCode;
|
||||
this.url = content.url;
|
||||
this.context = content.context;
|
||||
this.statusCode = statusCode;
|
||||
this.violations = content.violations;
|
||||
this.additionalMessages = content.additionalMessages;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "NotFoundError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "ConflictError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingLinkError extends Error {
|
||||
name = "MissingLinkError";
|
||||
}
|
||||
|
||||
export function createBackendError(content: BackendErrorContent, statusCode: number) {
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
return new NotFoundError(content, statusCode);
|
||||
case 409:
|
||||
return new ConflictError(content, statusCode);
|
||||
default:
|
||||
return new BackendError(content, "BackendError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackendError(response: Response) {
|
||||
return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2";
|
||||
}
|
||||
|
||||
export const TOKEN_EXPIRED_ERROR_CODE = "DDS8D8unr1";
|
||||
241
scm-ui/ui-api/src/groups.test.ts
Normal file
241
scm-ui/ui-api/src/groups.test.ts
Normal 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
141
scm-ui/ui-api/src/groups.ts
Normal 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
|
||||
};
|
||||
};
|
||||
48
scm-ui/ui-api/src/index.ts
Normal file
48
scm-ui/ui-api/src/index.ts
Normal 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
50
scm-ui/ui-api/src/keys.ts
Normal 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];
|
||||
};
|
||||
65
scm-ui/ui-api/src/links.test.ts
Normal file
65
scm-ui/ui-api/src/links.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
scm-ui/ui-api/src/links.ts
Normal file
38
scm-ui/ui-api/src/links.ts
Normal 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;
|
||||
};
|
||||
239
scm-ui/ui-api/src/login.test.ts
Normal file
239
scm-ui/ui-api/src/login.test.ts
Normal 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
118
scm-ui/ui-api/src/login.ts
Normal 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
|
||||
};
|
||||
};
|
||||
97
scm-ui/ui-api/src/namespaces.test.ts
Normal file
97
scm-ui/ui-api/src/namespaces.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
45
scm-ui/ui-api/src/namespaces.ts
Normal file
45
scm-ui/ui-api/src/namespaces.ts
Normal 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");
|
||||
};
|
||||
347
scm-ui/ui-api/src/permissions.test.ts
Normal file
347
scm-ui/ui-api/src/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
scm-ui/ui-api/src/permissions.ts
Normal file
158
scm-ui/ui-api/src/permissions.ts
Normal 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
|
||||
};
|
||||
};
|
||||
317
scm-ui/ui-api/src/plugins.test.ts
Normal file
317
scm-ui/ui-api/src/plugins.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
249
scm-ui/ui-api/src/plugins.ts
Normal file
249
scm-ui/ui-api/src/plugins.ts
Normal 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
|
||||
};
|
||||
};
|
||||
517
scm-ui/ui-api/src/repositories.test.ts
Normal file
517
scm-ui/ui-api/src/repositories.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
229
scm-ui/ui-api/src/repositories.ts
Normal file
229
scm-ui/ui-api/src/repositories.ts
Normal 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
|
||||
};
|
||||
};
|
||||
228
scm-ui/ui-api/src/repository-roles.test.ts
Normal file
228
scm-ui/ui-api/src/repository-roles.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
118
scm-ui/ui-api/src/repository-roles.ts
Normal file
118
scm-ui/ui-api/src/repository-roles.ts
Normal 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
|
||||
};
|
||||
};
|
||||
38
scm-ui/ui-api/src/reset.ts
Normal file
38
scm-ui/ui-api/src/reset.ts
Normal 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);
|
||||
};
|
||||
235
scm-ui/ui-api/src/sources.test.ts
Normal file
235
scm-ui/ui-api/src/sources.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
131
scm-ui/ui-api/src/sources.ts
Normal file
131
scm-ui/ui-api/src/sources.ts
Normal 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);
|
||||
};
|
||||
266
scm-ui/ui-api/src/tags.test.ts
Normal file
266
scm-ui/ui-api/src/tags.test.ts
Normal 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
119
scm-ui/ui-api/src/tags.ts
Normal 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
|
||||
};
|
||||
};
|
||||
37
scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts
Normal file
37
scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts
Normal 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 { QueryClient } from "react-query";
|
||||
|
||||
const createInfiniteCachingClient = () => {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default createInfiniteCachingClient;
|
||||
37
scm-ui/ui-api/src/tests/createWrapper.tsx
Normal file
37
scm-ui/ui-api/src/tests/createWrapper.tsx
Normal 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;
|
||||
43
scm-ui/ui-api/src/tests/indexLinks.ts
Normal file
43
scm-ui/ui-api/src/tests/indexLinks.ts
Normal 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: {}
|
||||
});
|
||||
};
|
||||
109
scm-ui/ui-api/src/urls.test.ts
Normal file
109
scm-ui/ui-api/src/urls.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 {
|
||||
concat,
|
||||
getNamespaceAndPageFromMatch,
|
||||
getQueryStringFromLocation,
|
||||
withEndingSlash
|
||||
} from "./urls";
|
||||
|
||||
describe("tests for withEndingSlash", () => {
|
||||
it("should append missing slash", () => {
|
||||
expect(withEndingSlash("abc")).toBe("abc/");
|
||||
});
|
||||
|
||||
it("should not append a second slash", () => {
|
||||
expect(withEndingSlash("abc/")).toBe("abc/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("concat tests", () => {
|
||||
it("should concat the parts to a single url", () => {
|
||||
expect(concat("a")).toBe("a");
|
||||
expect(concat("a", "b")).toBe("a/b");
|
||||
expect(concat("a", "b", "c")).toBe("a/b/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tests for getNamespaceAndPageFromMatch", () => {
|
||||
function createMatch(namespace?: string, page?: string) {
|
||||
return {
|
||||
params: { namespace, page }
|
||||
};
|
||||
}
|
||||
|
||||
it("should return no namespace and page 1 for neither namespace nor page", () => {
|
||||
const match = createMatch();
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: undefined, page: 1 });
|
||||
});
|
||||
|
||||
it("should return no namespace and page 1 for 0 as only parameter", () => {
|
||||
const match = createMatch("0");
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: undefined, page: 1 });
|
||||
});
|
||||
|
||||
it("should return no namespace and given number as page, when only a number is given", () => {
|
||||
const match = createMatch("42");
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: undefined, page: 42 });
|
||||
});
|
||||
|
||||
it("should return big number as namespace and page 1, when only a big number is given", () => {
|
||||
const match = createMatch("1337");
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: "1337", page: 1 });
|
||||
});
|
||||
|
||||
it("should namespace and page 1, when only a string is given", () => {
|
||||
const match = createMatch("something");
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: "something", page: 1 });
|
||||
});
|
||||
|
||||
it("should namespace and given page, when namespace and page are given", () => {
|
||||
const match = createMatch("something", "42");
|
||||
expect(getNamespaceAndPageFromMatch(match)).toEqual({ namespace: "something", page: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tests for getQueryStringFromLocation", () => {
|
||||
function createLocation(search: string) {
|
||||
return {
|
||||
search
|
||||
};
|
||||
}
|
||||
|
||||
it("should return the query string", () => {
|
||||
const location = createLocation("?q=abc");
|
||||
expect(getQueryStringFromLocation(location)).toBe("abc");
|
||||
});
|
||||
|
||||
it("should return query string from multiple parameters", () => {
|
||||
const location = createLocation("?x=a&y=b&q=abc&z=c");
|
||||
expect(getQueryStringFromLocation(location)).toBe("abc");
|
||||
});
|
||||
|
||||
it("should return undefined if q is not available", () => {
|
||||
const location = createLocation("?x=a&y=b&z=c");
|
||||
expect(getQueryStringFromLocation(location)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
98
scm-ui/ui-api/src/urls.ts
Normal file
98
scm-ui/ui-api/src/urls.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 queryString from "query-string";
|
||||
|
||||
//@ts-ignore
|
||||
export const contextPath = window.ctxPath || "";
|
||||
|
||||
export function withContextPath(path: string) {
|
||||
return contextPath + path;
|
||||
}
|
||||
|
||||
export function withEndingSlash(url: string) {
|
||||
if (url.endsWith("/")) {
|
||||
return url;
|
||||
}
|
||||
return url + "/";
|
||||
}
|
||||
|
||||
export function concat(base: string, ...parts: string[]) {
|
||||
let url = base;
|
||||
for (const p of parts) {
|
||||
url = withEndingSlash(url) + p;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getNamespaceAndPageFromMatch(match: any) {
|
||||
const namespaceFromMatch: string = match.params.namespace;
|
||||
const pageFromMatch: string = match.params.page;
|
||||
|
||||
if (!namespaceFromMatch && !pageFromMatch) {
|
||||
return { namespace: undefined, page: 1 };
|
||||
}
|
||||
|
||||
if (!pageFromMatch) {
|
||||
if (namespaceFromMatch.match(/^\d{1,3}$/)) {
|
||||
return { namespace: undefined, page: parsePageNumber(namespaceFromMatch) };
|
||||
} else {
|
||||
return { namespace: namespaceFromMatch, page: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
return { namespace: namespaceFromMatch, page: parsePageNumber(pageFromMatch) };
|
||||
}
|
||||
|
||||
export function getPageFromMatch(match: any) {
|
||||
return parsePageNumber(match.params.page);
|
||||
}
|
||||
|
||||
function parsePageNumber(pageAsString: string) {
|
||||
const page = parseInt(pageAsString, 10);
|
||||
if (isNaN(page) || !page) {
|
||||
return 1;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
export function getQueryStringFromLocation(location: any) {
|
||||
return location.search ? queryString.parse(location.search).q : undefined;
|
||||
}
|
||||
|
||||
export function stripEndingSlash(url: string) {
|
||||
if (url.endsWith("/")) {
|
||||
return url.substring(0, url.length - 1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function matchedUrlFromMatch(match: any) {
|
||||
return stripEndingSlash(match.url);
|
||||
}
|
||||
|
||||
export function matchedUrl(props: any) {
|
||||
const match = props.match;
|
||||
return matchedUrlFromMatch(match);
|
||||
}
|
||||
310
scm-ui/ui-api/src/users.test.ts
Normal file
310
scm-ui/ui-api/src/users.test.ts
Normal 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
198
scm-ui/ui-api/src/users.ts
Normal 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
|
||||
};
|
||||
};
|
||||
29
scm-ui/ui-api/src/utils.ts
Normal file
29
scm-ui/ui-api/src/utils.ts
Normal 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("&");
|
||||
};
|
||||
Reference in New Issue
Block a user