diff --git a/gradle/changelog/stale_while_revalidate.yaml b/gradle/changelog/stale_while_revalidate.yaml new file mode 100644 index 0000000000..5dee4cc4f9 --- /dev/null +++ b/gradle/changelog/stale_while_revalidate.yaml @@ -0,0 +1,2 @@ +- type: changed + description: improve frontend performance with stale while revalidate pattern ([#1555](https://github.com/scm-manager/scm-manager/pull/1555)) diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index a6cd68739f..f9facf852a 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index d320ca5f8e..a820fc2cfc 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-legacy-plugin/package.json b/scm-plugins/scm-legacy-plugin/package.json index c64d2f54bf..e46052ca44 100644 --- a/scm-plugins/scm-legacy-plugin/package.json +++ b/scm-plugins/scm-legacy-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index a7039805f8..580ae31528 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-ui/ui-api/package.json b/scm-ui/ui-api/package.json new file mode 100644 index 0000000000..cf53d8ac3e --- /dev/null +++ b/scm-ui/ui-api/package.json @@ -0,0 +1,43 @@ +{ + "name": "@scm-manager/ui-api", + "version": "2.13.1-SNAPSHOT", + "description": "React hook api for the SCM-Manager backend", + "main": "src/index.ts", + "files": [ + "dist", + "src" + ], + "repository": "https://github.com/scm-manager/scm-manager", + "author": "SCM Team ", + "license": "MIT", + "scripts": { + "test": "jest src/", + "typecheck": "tsc" + }, + "devDependencies": { + "@scm-manager/babel-preset": "^2.11.2", + "@scm-manager/eslint-config": "^2.10.1", + "@scm-manager/jest-preset": "^2.12.7", + "@scm-manager/prettier-config": "^2.10.1", + "@scm-manager/tsconfig": "^2.11.2", + "@testing-library/react-hooks": "^5.0.3", + "react-test-renderer": "^17.0.1" + }, + "dependencies": { + "@scm-manager/ui-types": "^2.13.1-SNAPSHOT", + "fetch-mock-jest": "^1.5.1", + "react": "^16.8.6", + "react-query": "^3.5.16", + "query-string": "5" + }, + "babel": { + "presets": [ + "@scm-manager/babel-preset" + ] + }, + "jest": { + "preset": "@scm-manager/jest-preset" + }, + "prettier": "@scm-manager/prettier-config", + "private": true +} diff --git a/scm-ui/ui-api/src/ApiProvider.test.tsx b/scm-ui/ui-api/src/ApiProvider.test.tsx new file mode 100644 index 0000000000..e944885260 --- /dev/null +++ b/scm-ui/ui-api/src/ApiProvider.test.tsx @@ -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 }) => {children}; + }; + + 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"); + }); +}); diff --git a/scm-ui/ui-api/src/ApiProvider.tsx b/scm-ui/ui-api/src/ApiProvider.tsx new file mode 100644 index 0000000000..fe18a73166 --- /dev/null +++ b/scm-ui/ui-api/src/ApiProvider.tsx @@ -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 = ({ 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 ( + + + {children} + + + + ); +}; + +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; diff --git a/scm-ui/ui-api/src/LegacyContext.test.tsx b/scm-ui/ui-api/src/LegacyContext.test.tsx new file mode 100644 index 0000000000..1498a9a60c --- /dev/null +++ b/scm-ui/ui-api/src/LegacyContext.test.tsx @@ -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 }) => {children}; + }; + + 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(); + }); +}); diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts b/scm-ui/ui-api/src/LegacyContext.tsx similarity index 62% rename from scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts rename to scm-ui/ui-api/src/LegacyContext.tsx index 32699882a3..aacd07e0b2 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts +++ b/scm-ui/ui-api/src/LegacyContext.tsx @@ -22,32 +22,24 @@ * SOFTWARE. */ -import { apiClient } from "@scm-manager/ui-components"; +import { IndexResources, Me } from "@scm-manager/ui-types"; +import React, { createContext, FC, useContext } from "react"; -const waitForRestart = () => { - const endTime = Number(new Date()) + 60000; - let started = false; - - const executor = (resolve, reject) => { - // we need some initial delay - if (!started) { - started = true; - setTimeout(executor, 1000, resolve, reject); - } else { - apiClient - .get("") - .then(resolve) - .catch(() => { - if (Number(new Date()) < endTime) { - setTimeout(executor, 500, resolve, reject); - } else { - reject(new Error("timeout reached")); - } - }); - } - }; - - return new Promise(executor); +export type LegacyContext = { + onIndexFetched?: (index: IndexResources) => void; + onMeFetched?: (me: Me) => void; }; -export default waitForRestart; +const Context = createContext(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 = ({ onIndexFetched, onMeFetched, children }) => ( + {children} +); diff --git a/scm-ui/ui-api/src/admin.test.ts b/scm-ui/ui-api/src/admin.test.ts new file mode 100644 index 0000000000..35b85bbb26 --- /dev/null +++ b/scm-ui/ui-api/src/admin.test.ts @@ -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); + }); + }); +}); diff --git a/scm-ui/ui-webapp/src/users/components/convertUser.ts b/scm-ui/ui-api/src/admin.ts similarity index 73% rename from scm-ui/ui-webapp/src/users/components/convertUser.ts rename to scm-ui/ui-api/src/admin.ts index a37c8879c9..4a8cbbb7f0 100644 --- a/scm-ui/ui-webapp/src/users/components/convertUser.ts +++ b/scm-ui/ui-api/src/admin.ts @@ -22,25 +22,14 @@ * SOFTWARE. */ +import { ApiResult, useRequiredIndexLink } from "./base"; +import { UpdateInfo } from "@scm-manager/ui-types"; +import { useQuery } from "react-query"; import { apiClient } from "@scm-manager/ui-components"; -import { CONTENT_TYPE_USER } from "../modules/users"; -export function convertToInternal(url: string, newPassword: string) { - return apiClient - .put( - url, - { - newPassword - }, - CONTENT_TYPE_USER - ) - .then(response => { - return response; - }); -} - -export function convertToExternal(url: string) { - return apiClient.put(url, {}, CONTENT_TYPE_USER).then(response => { - return response; - }); -} +export const useUpdateInfo = (): ApiResult => { + const indexLink = useRequiredIndexLink("updateInfo"); + return useQuery("updateInfo", () => + apiClient.get(indexLink).then(response => (response.status === 204 ? null : response.json())) + ); +}; diff --git a/scm-ui/ui-components/src/apiclient.test.ts b/scm-ui/ui-api/src/apiclient.test.ts similarity index 100% rename from scm-ui/ui-components/src/apiclient.test.ts rename to scm-ui/ui-api/src/apiclient.test.ts diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-api/src/apiclient.ts similarity index 90% rename from scm-ui/ui-components/src/apiclient.ts rename to scm-ui/ui-api/src/apiclient.ts index 5378bbc944..b332ce22d7 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-api/src/apiclient.ts @@ -23,14 +23,7 @@ */ import { contextPath } from "./urls"; -import { - createBackendError, - ForbiddenError, - isBackendError, - UnauthorizedError, - BackendErrorContent, - TOKEN_EXPIRED_ERROR_CODE -} from "./errors"; +import { BackendErrorContent, createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; type SubscriptionEvent = { type: string; @@ -157,11 +150,14 @@ export function createUrlWithIdentifiers(url: string): string { type ErrorListener = (error: Error) => void; +type RequestListener = (url: string, options?: RequestInit) => void; + class ApiClient { errorListeners: ErrorListener[] = []; + requestListeners: RequestListener[] = []; get = (url: string): Promise => { - return fetch(createUrl(url), applyFetchOptions({})) + return this.request(url, applyFetchOptions({})) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -204,7 +200,7 @@ class ApiClient { method: "HEAD" }; options = applyFetchOptions(options); - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -214,7 +210,7 @@ class ApiClient { method: "DELETE" }; options = applyFetchOptions(options); - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -260,7 +256,7 @@ class ApiClient { options.headers["Content-Type"] = contentType; } - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -294,10 +290,23 @@ class ApiClient { return () => es.close(); } + onRequest = (requestListener: RequestListener) => { + this.requestListeners.push(requestListener); + }; + onError = (errorListener: ErrorListener) => { this.errorListeners.push(errorListener); }; + private request = (url: string, options: RequestInit) => { + this.notifyRequestListeners(url, options); + return fetch(createUrl(url), options); + }; + + private notifyRequestListeners = (url: string, options: RequestInit) => { + this.requestListeners.forEach(requestListener => requestListener(url, options)); + }; + private notifyAndRethrow = (error: Error): never => { this.errorListeners.forEach(errorListener => errorListener(error)); throw error; diff --git a/scm-ui/ui-api/src/base.test.ts b/scm-ui/ui-api/src/base.test.ts new file mode 100644 index 0000000000..1b9a7d14c6 --- /dev/null +++ b/scm-ui/ui-api/src/base.test.ts @@ -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("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(); + }); +}); diff --git a/scm-ui/ui-api/src/base.ts b/scm-ui/ui-api/src/base.ts new file mode 100644 index 0000000000..3f9c93df56 --- /dev/null +++ b/scm-ui/ui-api/src/base.ts @@ -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 = { + isLoading: boolean; + error: Error | null; + data?: T; +}; + +export const useIndex = (): ApiResult => { + const legacy = useLegacyContext(); + return useQuery("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 = (name: string): ApiResult => { + const link = useIndexLink(name); + return useQuery(name, () => apiClient.get(link!).then(response => response.json()), { + enabled: !!link + }); +}; diff --git a/scm-ui/ui-api/src/branches.test.ts b/scm-ui/ui-api/src/branches.test.ts new file mode 100644 index 0000000000..cf200b5e16 --- /dev/null +++ b/scm-ui/ui-api/src/branches.test.ts @@ -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([ + "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([ + "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); + }); + }); +}); diff --git a/scm-ui/ui-api/src/branches.ts b/scm-ui/ui-api/src/branches.ts new file mode 100644 index 0000000000..41b9c5493c --- /dev/null +++ b/scm-ui/ui-api/src/branches.ts @@ -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 => { + const link = requiredLink(repository, "branches"); + return useQuery( + 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 => { + const link = requiredLink(repository, "branches"); + return useQuery(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(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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/changesets.test.ts b/scm-ui/ui-api/src/changesets.test.ts new file mode 100644 index 0000000000..7a50aa9d19 --- /dev/null +++ b/scm-ui/ui-api/src/changesets.test.ts @@ -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"); + }); + }); +}); diff --git a/scm-ui/ui-api/src/changesets.ts b/scm-ui/ui-api/src/changesets.ts new file mode 100644 index 0000000000..63fbb12934 --- /dev/null +++ b/scm-ui/ui-api/src/changesets.ts @@ -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 => { + 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(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 => { + const changesetsLink = requiredLink(repository, "changesets"); + return useQuery(changesetQueryKey(repository, id), () => + apiClient.get(concat(changesetsLink, id)).then(response => response.json()) + ); +}; diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts new file mode 100644 index 0000000000..0e08bc3e2d --- /dev/null +++ b/scm-ui/ui-api/src/config.test.ts @@ -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(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/config.ts b/scm-ui/ui-api/src/config.ts new file mode 100644 index 0000000000..78bdd81239 --- /dev/null +++ b/scm-ui/ui-api/src/config.ts @@ -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 => { + const indexLink = useIndexLink("config"); + return useQuery("config", () => apiClient.get(indexLink!).then(response => response.json()), { + enabled: !!indexLink + }); +}; + +export const useUpdateConfig = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data, reset } = useMutation( + 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 + }; +}; diff --git a/scm-ui/ui-components/src/errors.test.ts b/scm-ui/ui-api/src/errors.test.ts similarity index 100% rename from scm-ui/ui-components/src/errors.test.ts rename to scm-ui/ui-api/src/errors.test.ts diff --git a/scm-ui/ui-components/src/errors.ts b/scm-ui/ui-api/src/errors.ts similarity index 99% rename from scm-ui/ui-components/src/errors.ts rename to scm-ui/ui-api/src/errors.ts index 41e44f0702..a7e293ac85 100644 --- a/scm-ui/ui-components/src/errors.ts +++ b/scm-ui/ui-api/src/errors.ts @@ -26,11 +26,13 @@ type Context = { type: string; id: string; }[]; + export type Violation = { path?: string; message: string; key?: string; }; + export type AdditionalMessage = { key?: string; message?: string; diff --git a/scm-ui/ui-api/src/groups.test.ts b/scm-ui/ui-api/src/groups.test.ts new file mode 100644 index 0000000000..b8eba35f26 --- /dev/null +++ b/scm-ui/ui-api/src/groups.test.ts @@ -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(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/groups.ts b/scm-ui/ui-api/src/groups.ts new file mode 100644 index 0000000000..515b6e4a54 --- /dev/null +++ b/scm-ui/ui-api/src/groups.ts @@ -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 => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("groups"); + + const queryParams: Record = {}; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["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 => { + const indexLink = useRequiredIndexLink("groups"); + return useQuery(["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(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( + 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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts new file mode 100644 index 0000000000..5429990ba4 --- /dev/null +++ b/scm-ui/ui-api/src/index.ts @@ -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"; diff --git a/scm-ui/ui-api/src/keys.ts b/scm-ui/ui-api/src/keys.ts new file mode 100644 index 0000000000..7690379ce9 --- /dev/null +++ b/scm-ui/ui-api/src/keys.ts @@ -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]; +}; diff --git a/scm-ui/ui-api/src/links.test.ts b/scm-ui/ui-api/src/links.test.ts new file mode 100644 index 0000000000..d1628cc7ed --- /dev/null +++ b/scm-ui/ui-api/src/links.test.ts @@ -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(); + }); +}); diff --git a/scm-ui/ui-api/src/links.ts b/scm-ui/ui-api/src/links.ts new file mode 100644 index 0000000000..970edcbdd7 --- /dev/null +++ b/scm-ui/ui-api/src/links.ts @@ -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; +}; diff --git a/scm-ui/ui-api/src/login.test.ts b/scm-ui/ui-api/src/login.test.ts new file mode 100644 index 0000000000..3c83ea1f27 --- /dev/null +++ b/scm-ui/ui-api/src/login.test.ts @@ -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(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/login.ts b/scm-ui/ui-api/src/login.ts new file mode 100644 index 0000000000..eba8c112c6 --- /dev/null +++ b/scm-ui/ui-api/src/login.ts @@ -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 => { + const legacy = useLegacyContext(); + const link = useIndexLink("me"); + return useQuery("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( + 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( + () => apiClient.delete(link!).then(() => true), + { + onSuccess: reset + } + ); + + const logout = () => { + mutate({}); + }; + + return { + logout: link && !data ? logout : undefined, + isLoading, + error + }; +}; diff --git a/scm-ui/ui-api/src/namespaces.test.ts b/scm-ui/ui-api/src/namespaces.test.ts new file mode 100644 index 0000000000..36a3d8a027 --- /dev/null +++ b/scm-ui/ui-api/src/namespaces.test.ts @@ -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"); + }); + }); +}); diff --git a/scm-ui/ui-api/src/namespaces.ts b/scm-ui/ui-api/src/namespaces.ts new file mode 100644 index 0000000000..341afdca4a --- /dev/null +++ b/scm-ui/ui-api/src/namespaces.ts @@ -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("namespaces"); +}; + +export const useNamespace = (name: string): ApiResult => { + const namespacesLink = useRequiredIndexLink("namespaces"); + return useQuery(["namespace", name], () => + apiClient.get(concat(namespacesLink, name)).then(response => response.json()) + ); +}; + +export const useNamespaceStrategies = () => { + return useIndexJsonResource("namespaceStrategies"); +}; diff --git a/scm-ui/ui-api/src/permissions.test.ts b/scm-ui/ui-api/src/permissions.test.ts new file mode 100644 index 0000000000..c37d214dc4 --- /dev/null +++ b/scm-ui/ui-api/src/permissions.test.ts @@ -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); + }); + }); +}); diff --git a/scm-ui/ui-api/src/permissions.ts b/scm-ui/ui-api/src/permissions.ts new file mode 100644 index 0000000000..e48fe83a67 --- /dev/null +++ b/scm-ui/ui-api/src/permissions.ts @@ -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 => { + return useIndexJsonResource("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 => { + const link = requiredLink(namespaceOrRepository, "permissions"); + const queryKey = createQueryKey(namespaceOrRepository); + return useQuery(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( + 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( + 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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/plugins.test.ts b/scm-ui/ui-api/src/plugins.test.ts new file mode 100644 index 0000000000..da13183e82 --- /dev/null +++ b/scm-ui/ui-api/src/plugins.test.ts @@ -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); + }); + }); +}); diff --git a/scm-ui/ui-api/src/plugins.ts b/scm-ui/ui-api/src/plugins.ts new file mode 100644 index 0000000000..634b5dcedb --- /dev/null +++ b/scm-ui/ui-api/src/plugins.ts @@ -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, + { initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {} +): Promise => { + const endTime = Number(new Date()) + 60000; + let started = false; + + const executor = (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(executor(data))); +}; + +export type UseAvailablePluginsOptions = { + enabled?: boolean; +}; + +export const useAvailablePlugins = ({ enabled }: UseAvailablePluginsOptions = {}): ApiResult => { + const indexLink = useRequiredIndexLink("availablePlugins"); + return useQuery( + ["plugins", "available"], + () => apiClient.get(indexLink).then(response => response.json()), + { + enabled, + retry: 3 + } + ); +}; + +export type UseInstalledPluginsOptions = { + enabled?: boolean; +}; + +export const useInstalledPlugins = ({ enabled }: UseInstalledPluginsOptions = {}): ApiResult => { + const indexLink = useRequiredIndexLink("installedPlugins"); + return useQuery( + ["plugins", "installed"], + () => apiClient.get(indexLink).then(response => response.json()), + { + enabled, + retry: 3 + } + ); +}; + +export const usePendingPlugins = (): ApiResult => { + const indexLink = useIndexLink("pendingPlugins"); + return useQuery( + ["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( + ({ 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( + ({ 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( + ({ 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( + ({ 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( + pending => apiClient.post(requiredLink(pending, "cancel")), + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + update: (pending: PendingPlugins) => mutate(pending), + isLoading, + error, + isCancelled: !!data + }; +}; diff --git a/scm-ui/ui-api/src/repositories.test.ts b/scm-ui/ui-api/src/repositories.test.ts new file mode 100644 index 0000000000..c262f5586c --- /dev/null +++ b/scm-ui/ui-api/src/repositories.test.ts @@ -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); + }); + }); +}); diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts new file mode 100644 index 0000000000..e5160cc481 --- /dev/null +++ b/scm-ui/ui-api/src/repositories.ts @@ -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 => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("repositories"); + const namespaceLink = (request?.namespace?._links.repositories as Link)?.href; + const link = namespaceLink || indexLink; + + const queryParams: Record = { + sortBy: "namespaceAndName" + }; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + return useQuery( + ["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( + 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("repositoryTypes"); + +export const useRepository = (namespace: string, name: string): ApiResult => { + const link = useRequiredIndexLink("repositories"); + return useQuery(["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( + 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( + 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( + 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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/repository-roles.test.ts b/scm-ui/ui-api/src/repository-roles.test.ts new file mode 100644 index 0000000000..10a05474ea --- /dev/null +++ b/scm-ui/ui-api/src/repository-roles.test.ts @@ -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(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/repository-roles.ts b/scm-ui/ui-api/src/repository-roles.ts new file mode 100644 index 0000000000..ddc5ec48f6 --- /dev/null +++ b/scm-ui/ui-api/src/repository-roles.ts @@ -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 => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("repositoryRoles"); + + const queryParams: Record = {}; + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["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 => { + const indexLink = useRequiredIndexLink("repositoryRoles"); + return useQuery(["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( + 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( + 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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/reset.ts b/scm-ui/ui-api/src/reset.ts new file mode 100644 index 0000000000..abb5a05ac1 --- /dev/null +++ b/scm-ui/ui-api/src/reset.ts @@ -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); +}; diff --git a/scm-ui/ui-api/src/sources.test.ts b/scm-ui/ui-api/src/sources.test.ts new file mode 100644 index 0000000000..4a08387983 --- /dev/null +++ b/scm-ui/ui-api/src/sources.test.ts @@ -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); + }); + }); +}); diff --git a/scm-ui/ui-api/src/sources.ts b/scm-ui/ui-api/src/sources.ts new file mode 100644 index 0000000000..49fe5fb82b --- /dev/null +++ b/scm-ui/ui-api/src/sources.ts @@ -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( + 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); +}; diff --git a/scm-ui/ui-api/src/tags.test.ts b/scm-ui/ui-api/src/tags.test.ts new file mode 100644 index 0000000000..df1a56ad46 --- /dev/null +++ b/scm-ui/ui-api/src/tags.test.ts @@ -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(["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]); + }); + }); +}); diff --git a/scm-ui/ui-api/src/tags.ts b/scm-ui/ui-api/src/tags.ts new file mode 100644 index 0000000000..441360f224 --- /dev/null +++ b/scm-ui/ui-api/src/tags.ts @@ -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 => { + const link = requiredLink(repository, "tags"); + return useQuery( + 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 => { + const link = requiredLink(repository, "tags"); + return useQuery(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(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( + 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 + }; +}; diff --git a/scm-ui/ui-components/.storybook/withRedux.js b/scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts similarity index 78% rename from scm-ui/ui-components/.storybook/withRedux.js rename to scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts index 0abc2149ca..88f56b9d38 100644 --- a/scm-ui/ui-components/.storybook/withRedux.js +++ b/scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts @@ -22,20 +22,16 @@ * SOFTWARE. */ -import React from "react"; -import {createStore} from "redux"; -import { Provider } from 'react-redux' +import { QueryClient } from "react-query"; -const reducer = (state, action) => { - return state; +const createInfiniteCachingClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity + } + } + }); }; -const withRedux = (storyFn) => { - return React.createElement(Provider, { - store: createStore(reducer, {}), - children: storyFn() - }); -} - - -export default withRedux; +export default createInfiniteCachingClient; diff --git a/scm-ui/ui-api/src/tests/createWrapper.tsx b/scm-ui/ui-api/src/tests/createWrapper.tsx new file mode 100644 index 0000000000..40a8434f23 --- /dev/null +++ b/scm-ui/ui-api/src/tests/createWrapper.tsx @@ -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 }) => ( + + {children} + + ); +}; + +export default createWrapper; diff --git a/scm-ui/ui-api/src/tests/indexLinks.ts b/scm-ui/ui-api/src/tests/indexLinks.ts new file mode 100644 index 0000000000..b55b874fbd --- /dev/null +++ b/scm-ui/ui-api/src/tests/indexLinks.ts @@ -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: {} + }); +}; diff --git a/scm-ui/ui-components/src/urls.test.ts b/scm-ui/ui-api/src/urls.test.ts similarity index 100% rename from scm-ui/ui-components/src/urls.test.ts rename to scm-ui/ui-api/src/urls.test.ts diff --git a/scm-ui/ui-components/src/urls.ts b/scm-ui/ui-api/src/urls.ts similarity index 96% rename from scm-ui/ui-components/src/urls.ts rename to scm-ui/ui-api/src/urls.ts index cae061b225..50e564d848 100644 --- a/scm-ui/ui-components/src/urls.ts +++ b/scm-ui/ui-api/src/urls.ts @@ -23,7 +23,6 @@ */ import queryString from "query-string"; -import { RouteComponentProps } from "react-router-dom"; //@ts-ignore export const contextPath = window.ctxPath || ""; @@ -93,7 +92,7 @@ export function matchedUrlFromMatch(match: any) { return stripEndingSlash(match.url); } -export function matchedUrl(props: RouteComponentProps) { +export function matchedUrl(props: any) { const match = props.match; return matchedUrlFromMatch(match); } diff --git a/scm-ui/ui-api/src/users.test.ts b/scm-ui/ui-api/src/users.test.ts new file mode 100644 index 0000000000..c3b34da2af --- /dev/null +++ b/scm-ui/ui-api/src/users.test.ts @@ -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(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/users.ts b/scm-ui/ui-api/src/users.ts new file mode 100644 index 0000000000..a8f1dfdb6e --- /dev/null +++ b/scm-ui/ui-api/src/users.ts @@ -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 => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("users"); + + const queryParams: Record = {}; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["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 => { + const indexLink = useRequiredIndexLink("users"); + return useQuery(["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(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( + 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( + 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( + ({ 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( + 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 + }; +}; diff --git a/scm-ui/ui-api/src/utils.ts b/scm-ui/ui-api/src/utils.ts new file mode 100644 index 0000000000..711c140ca0 --- /dev/null +++ b/scm-ui/ui-api/src/utils.ts @@ -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) => { + return Object.keys(params) + .map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k])) + .join("&"); +}; diff --git a/scm-ui/ui-api/tsconfig.json b/scm-ui/ui-api/tsconfig.json new file mode 100644 index 0000000000..9aa573a38d --- /dev/null +++ b/scm-ui/ui-api/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@scm-manager/tsconfig", + "exclude": [ + "./scripts" + ] +} diff --git a/scm-ui/ui-components/.storybook/.babelrc b/scm-ui/ui-components/.storybook/.babelrc new file mode 100644 index 0000000000..a138b1182a --- /dev/null +++ b/scm-ui/ui-components/.storybook/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@scm-manager/babel-preset"] +} diff --git a/scm-ui/ui-components/.storybook/config.js b/scm-ui/ui-components/.storybook/config.js index af032d4054..bb24933bb1 100644 --- a/scm-ui/ui-components/.storybook/config.js +++ b/scm-ui/ui-components/.storybook/config.js @@ -25,12 +25,9 @@ import i18next from "i18next"; import { initReactI18next } from "react-i18next"; import { addDecorator, configure } from "@storybook/react"; import { withI18next } from "storybook-addon-i18next"; - import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss"; import React from "react"; -import { MemoryRouter } from "react-router-dom"; -import withRedux from "./withRedux"; - +import withApiProvider from "./withApiProvider"; let i18n = i18next; @@ -38,11 +35,10 @@ let i18n = i18next; // and not for storyshots if (!process.env.JEST_WORKER_ID) { const Backend = require("i18next-fetch-backend"); - i18n = i18n.use(Backend.default) + i18n = i18n.use(Backend.default); } -i18n -.use(initReactI18next).init({ +i18n.use(initReactI18next).init({ whitelist: ["en", "de", "es"], lng: "en", fallbackLng: "en", @@ -71,6 +67,6 @@ addDecorator( }) ); -addDecorator(withRedux); +addDecorator(withApiProvider); configure(require.context("../src", true, /\.stories\.tsx?$/), module); diff --git a/scm-ui/ui-components/.storybook/webpack.config.js b/scm-ui/ui-components/.storybook/webpack.config.js deleted file mode 100644 index 9b7c1b6d26..0000000000 --- a/scm-ui/ui-components/.storybook/webpack.config.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -const WorkerPlugin = require("worker-plugin"); - -module.exports = { - module: { - rules: [ - { - parser: { - system: false, - systemjs: false - } - }, - { - test: /\.(js|ts|jsx|tsx)$/, - exclude: /node_modules/, - use: [ - { - loader: "babel-loader", - options: { - cacheDirectory: true, - presets: ["@scm-manager/babel-preset"] - } - } - ] - }, - { - test: /\.(css|scss|sass)$/i, - use: [ - // Creates `style` nodes from JS strings - "style-loader", - { - loader: "css-loader", - options: { - // Run `postcss-loader` on each CSS `@import`, do not forget that `sass-loader` compile non CSS `@import`'s into a single file - // If you need run `sass-loader` and `postcss-loader` on each CSS `@import` please set it to `2` - importLoaders: 1, - // Automatically enable css modules for files satisfying `/\.module\.\w+$/i` RegExp. - modules: { auto: true } - } - }, - // Compiles Sass to CSS - "sass-loader" - ] - }, - { - test: /\.(png|svg|jpg|gif|woff2?|eot|ttf)$/, - use: ["file-loader"] - } - ] - }, - resolve: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"] - }, - plugins: [ - new WorkerPlugin() - ] -}; diff --git a/scm-ui/ui-components/.storybook/withApiProvider.js b/scm-ui/ui-components/.storybook/withApiProvider.js new file mode 100644 index 0000000000..1ae78e15f9 --- /dev/null +++ b/scm-ui/ui-components/.storybook/withApiProvider.js @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import * as React from "react"; +import { ApiProvider } from "@scm-manager/ui-api"; + +const withApiProvider = (storyFn) => { + return React.createElement(ApiProvider, { + index: { + version: "x.y.z", + _links: {} + }, + me: { + name: "trillian", + displayName: "Trillian McMillan", + mail: "trillian@hitchhiker.com", + groups: [], + _links: {} + }, + children: storyFn() + }); +} + +export default withApiProvider; diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index d78518e1e7..3465b5a3bb 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -18,15 +18,15 @@ "update-storyshots": "jest --testPathPattern=\"storyshots.test.ts\" --collectCoverage=false -u" }, "devDependencies": { - "@scm-manager/babel-preset": "^2.10.1", + "@scm-manager/babel-preset": "^2.11.2", "@scm-manager/eslint-config": "^2.12.0", - "@scm-manager/jest-preset": "^2.12.4", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/prettier-config": "^2.10.1", - "@scm-manager/tsconfig": "^2.10.1", + "@scm-manager/tsconfig": "^2.11.2", "@scm-manager/ui-tests": "^2.13.1-SNAPSHOT", - "@storybook/addon-actions": "^6.0.28", - "@storybook/addon-storyshots": "^6.0.28", - "@storybook/react": "^6.0.28", + "@storybook/addon-actions": "^6.1.17", + "@storybook/addon-storyshots": "^6.1.17", + "@storybook/react": "^6.1.17", "@types/classnames": "^2.2.9", "@types/css": "^0.0.31", "@types/enzyme": "^3.10.3", @@ -51,12 +51,12 @@ "react-test-renderer": "^16.10.2", "storybook-addon-i18next": "^1.3.0", "to-camel-case": "^1.0.0", - "typescript": "^3.7.2", "worker-plugin": "^3.2.0" }, "dependencies": { "@scm-manager/ui-extensions": "^2.13.1-SNAPSHOT", "@scm-manager/ui-types": "^2.13.1-SNAPSHOT", + "@scm-manager/ui-api": "^2.13.1-SNAPSHOT", "classnames": "^2.2.6", "date-fns": "^2.4.1", "gitdiff-parser": "^0.1.2", diff --git a/scm-ui/ui-components/src/BackendErrorNotification.tsx b/scm-ui/ui-components/src/BackendErrorNotification.tsx index a680304e73..aae7633c49 100644 --- a/scm-ui/ui-components/src/BackendErrorNotification.tsx +++ b/scm-ui/ui-components/src/BackendErrorNotification.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React from "react"; -import { BackendError } from "./errors"; +import { BackendError } from "@scm-manager/ui-api"; import Notification from "./Notification"; import { WithTranslation, withTranslation } from "react-i18next"; diff --git a/scm-ui/ui-components/src/Breadcrumb.tsx b/scm-ui/ui-components/src/Breadcrumb.tsx index 12f99f1539..986fab0089 100644 --- a/scm-ui/ui-components/src/Breadcrumb.tsx +++ b/scm-ui/ui-components/src/Breadcrumb.tsx @@ -27,16 +27,16 @@ import { useHistory, useLocation, Link } from "react-router-dom"; import classNames from "classnames"; import styled from "styled-components"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; -import { Branch, Repository } from "@scm-manager/ui-types"; +import { Branch, Repository, File } from "@scm-manager/ui-types"; import Icon from "./Icon"; import Tooltip from "./Tooltip"; import copyToClipboard from "./CopyToClipboard"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; type Props = { repository: Repository; - branch: Branch; - defaultBranch: Branch; + branch?: Branch; + defaultBranch?: Branch; revision: string; path: string; baseUrl: string; @@ -53,6 +53,7 @@ const PermaLinkWrapper = styled.div` color: #dbdbdb; opacity: 0.75; } + &:hover i { color: #b5b5b5; opacity: 1; @@ -108,7 +109,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa } return (
  • - {pathFragment} + {pathFragment}
  • ); }); @@ -120,7 +121,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa history.push(location.pathname); setCopying(true); copyToClipboard( - window.location.protocol + "//" + window.location.host + withContextPath(permalink || location.pathname) + window.location.protocol + "//" + window.location.host + urls.withContextPath(permalink || location.pathname) ).finally(() => setCopying(false)); }; @@ -161,7 +162,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa branch: branch ? branch : defaultBranch, path, sources, - repository, + repository }} renderAll={true} /> diff --git a/scm-ui/ui-components/src/ErrorBoundary.tsx b/scm-ui/ui-components/src/ErrorBoundary.tsx index 3d0144b95b..616e315498 100644 --- a/scm-ui/ui-components/src/ErrorBoundary.tsx +++ b/scm-ui/ui-components/src/ErrorBoundary.tsx @@ -21,31 +21,97 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { ComponentType, ReactNode } from "react"; +import React, { FC, ReactNode, useEffect } from "react"; import ErrorNotification from "./ErrorNotification"; -import { MissingLinkError } from "./errors"; -import { withContextPath } from "./urls"; -import { withRouter, RouteComponentProps } from "react-router-dom"; +import { MissingLinkError, urls, useIndexLink } from "@scm-manager/ui-api"; +import { RouteComponentProps, useLocation, withRouter } from "react-router-dom"; import ErrorPage from "./ErrorPage"; -import { WithTranslation, withTranslation } from "react-i18next"; -import { compose } from "redux"; -import { connect } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { Subtitle, Title } from "./layout"; +import Icon from "./Icon"; +import styled from "styled-components"; -type ExportedProps = { - fallback?: React.ComponentType; - children: ReactNode; - loginLink?: string; +type State = { + error?: Error; + errorInfo?: ErrorInfo; }; -type Props = WithTranslation & RouteComponentProps & ExportedProps; +type ExportedProps = { + fallback?: React.ComponentType; + children: ReactNode; +}; + +type Props = RouteComponentProps & ExportedProps; type ErrorInfo = { componentStack: string; }; -type State = { - error?: Error; - errorInfo?: ErrorInfo; +type ErrorDisplayProps = { + fallback?: React.ComponentType; + error: Error; + errorInfo: ErrorInfo; +}; + +const RedirectIconContainer = styled.div` + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 256px; +`; + +const RedirectPage = () => { + const [t] = useTranslation("commons"); + // we use an icon instead of loading spinner, + // because a redirect is synchron and a spinner does not spin on a synchron action + return ( +
    +
    + {t("errorBoundary.redirect.title")} + {t("errorBoundary.redirect.subtitle")} + + + +
    +
    + ); +}; + +const ErrorDisplay: FC = ({ error, errorInfo, fallback: FallbackComponent }) => { + const loginLink = useIndexLink("login"); + const [t] = useTranslation("commons"); + const location = useLocation(); + const isMissingLink = error instanceof MissingLinkError; + useEffect(() => { + if (isMissingLink && loginLink) { + window.location.assign(urls.withContextPath("/login?from=" + location.pathname)); + } + }, [isMissingLink, loginLink, location.pathname]); + + if (isMissingLink) { + if (loginLink) { + // we can render a loading screen, + // because the effect hook above should redirect + return ; + } else { + // missing link error without login link means we have no permissions + // and we should render an error + return ( + + ); + } + } + + if (!FallbackComponent) { + return ; + } + + const fallbackProps = { + error, + errorInfo + }; + + return ; }; class ErrorBoundary extends React.Component { @@ -62,62 +128,20 @@ class ErrorBoundary extends React.Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - this.setState( - { - error, - errorInfo - }, - () => this.redirectToLogin(error) - ); + this.setState({ + error, + errorInfo + }); } - redirectToLogin = (error: Error) => { - const { loginLink, location } = this.props; - if (error instanceof MissingLinkError) { - if (loginLink) { - window.location.assign(withContextPath("/login?from=" + location.pathname)); - } - } - }; - - renderError = () => { - const { t } = this.props; - const { error } = this.state; - - let FallbackComponent = this.props.fallback; - - if (error instanceof MissingLinkError) { - return ( - - ); - } - - if (!FallbackComponent) { - FallbackComponent = ErrorNotification; - } - - return ; - }; - render() { - const { error } = this.state; - if (error) { - return this.renderError(); + const { fallback } = this.props; + const { error, errorInfo } = this.state; + if (error && errorInfo) { + return ; } return this.props.children; } } -const mapStateToProps = (state: any) => { - const loginLink = state.indexResources?.links?.login?.href; - - return { - loginLink - }; -}; - -export default compose>( - withRouter, - withTranslation("commons"), - connect(mapStateToProps) -)(ErrorBoundary); +export default withRouter(ErrorBoundary); diff --git a/scm-ui/ui-components/src/ErrorNotification.tsx b/scm-ui/ui-components/src/ErrorNotification.tsx index 9bb685175b..61df6cf868 100644 --- a/scm-ui/ui-components/src/ErrorNotification.tsx +++ b/scm-ui/ui-components/src/ErrorNotification.tsx @@ -22,15 +22,14 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; -import { BackendError, ForbiddenError, UnauthorizedError } from "./errors"; +import { useTranslation } from "react-i18next"; +import { BackendError, ForbiddenError, UnauthorizedError, urls } from "@scm-manager/ui-api"; import Notification from "./Notification"; import BackendErrorNotification from "./BackendErrorNotification"; import { useLocation } from "react-router-dom"; -import { withContextPath } from "./urls"; -type Props = WithTranslation & { - error?: Error; +type Props = { + error?: Error | null; }; const LoginLink: FC = () => { @@ -38,37 +37,35 @@ const LoginLink: FC = () => { const location = useLocation(); const from = encodeURIComponent(location.hash ? location.pathname + location.hash : location.pathname); - return {t("errorNotification.loginLink")}; + return {t("errorNotification.loginLink")}; }; -class ErrorNotification extends React.Component { - render() { - const { t, error } = this.props; - if (error) { - if (error instanceof BackendError) { - return ; - } else if (error instanceof UnauthorizedError) { - return ( - - {t("errorNotification.prefix")}: {t("errorNotification.timeout")} - - ); - } else if (error instanceof ForbiddenError) { - return ( - - {t("errorNotification.prefix")}: {t("errorNotification.forbidden")} - - ); - } else { - return ( - - {t("errorNotification.prefix")}: {error.message} - - ); - } +const ErrorNotification: FC = ({ error }) => { + const [t] = useTranslation("commons"); + if (error) { + if (error instanceof BackendError) { + return ; + } else if (error instanceof UnauthorizedError) { + return ( + + {t("errorNotification.prefix")}: {t("errorNotification.timeout")} + + ); + } else if (error instanceof ForbiddenError) { + return ( + + {t("errorNotification.prefix")}: {t("errorNotification.forbidden")} + + ); + } else { + return ( + + {t("errorNotification.prefix")}: {error.message} + + ); } - return null; } -} + return null; +}; -export default withTranslation("commons")(ErrorNotification); +export default ErrorNotification; diff --git a/scm-ui/ui-components/src/ErrorPage.tsx b/scm-ui/ui-components/src/ErrorPage.tsx index 7a2ac769c2..ae8f4749a0 100644 --- a/scm-ui/ui-components/src/ErrorPage.tsx +++ b/scm-ui/ui-components/src/ErrorPage.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import ErrorNotification from "./ErrorNotification"; -import { BackendError, ForbiddenError } from "./errors"; +import { BackendError, ForbiddenError } from "@scm-manager/ui-api"; type Props = { error: Error; diff --git a/scm-ui/ui-components/src/Image.tsx b/scm-ui/ui-components/src/Image.tsx index 56f32cde7d..2e55624fc7 100644 --- a/scm-ui/ui-components/src/Image.tsx +++ b/scm-ui/ui-components/src/Image.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React from "react"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; type Props = { src: string; @@ -37,7 +37,7 @@ class Image extends React.Component { if (src.startsWith("http")) { return src; } - return withContextPath(src); + return urls.withContextPath(src); }; render() { diff --git a/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx b/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx index d08cfcb2d2..d5c19079be 100644 --- a/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx @@ -25,17 +25,17 @@ import React, { FC } from "react"; import SyntaxHighlighter from "./SyntaxHighlighter"; import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions"; -import { connect } from "react-redux"; +import { useIndexLinks } from "@scm-manager/ui-api"; type Props = { language?: string; value: string; - indexLinks: { [key: string]: any }; }; -const MarkdownCodeRenderer: FC = (props) => { +const MarkdownCodeRenderer: FC = props => { const binder = useBinder(); - const { language, indexLinks } = props; + const indexLinks = useIndexLinks(); + const { language } = props; const extensionKey = `markdown-renderer.code.${language}`; if (binder.hasExtension(extensionKey, props)) { return ; @@ -43,12 +43,4 @@ const MarkdownCodeRenderer: FC = (props) => { return ; }; -const mapStateToProps = (state: any) => { - const indexLinks = state.indexResources.links; - - return { - indexLinks, - }; -}; - -export default connect(mapStateToProps)(MarkdownCodeRenderer); +export default MarkdownCodeRenderer; diff --git a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx index 8423098e3b..a2e1a67707 100644 --- a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx @@ -23,7 +23,7 @@ */ import React, { ReactNode } from "react"; import { withRouter, RouteComponentProps } from "react-router-dom"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; /** * Adds anchor links to markdown headings. @@ -54,7 +54,7 @@ function MarkdownHeadingRenderer(props: Props) { const heading = children.reduce(flatten, ""); const anchorId = headingToAnchorId(heading); const headingElement = React.createElement("h" + props.level, {}, props.children); - const href = withContextPath(props.location.pathname + "#" + anchorId); + const href = urls.withContextPath(props.location.pathname + "#" + anchorId); return ( diff --git a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx index fc38bd388b..b88da10601 100644 --- a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Link, useLocation } from "react-router-dom"; import ExternalLink from "./navigation/ExternalLink"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; const externalLinkRegex = new RegExp("^http(s)?://"); export const isExternalLink = (link: string) => { @@ -116,7 +116,7 @@ const MarkdownLinkRenderer: FC = ({ href, base, children }) => { } else if (isLinkWithProtocol(href)) { return {children}; } else if (isAnchorLink(href)) { - return {children}; + return {children}; } else { const localLink = createLocalLink(base, location.pathname, href); return {children}; diff --git a/scm-ui/ui-components/src/OverviewPageActions.tsx b/scm-ui/ui-components/src/OverviewPageActions.tsx index da9a2437cd..abfc12c890 100644 --- a/scm-ui/ui-components/src/OverviewPageActions.tsx +++ b/scm-ui/ui-components/src/OverviewPageActions.tsx @@ -30,7 +30,7 @@ import { FilterInput } from "./forms"; type Props = { showCreateButton: boolean; currentGroup: string; - groups: string[]; + groups?: string[]; link: string; groupSelected: (namespace: string) => void; label?: string; @@ -51,6 +51,7 @@ const OverviewPageActions: FC = ({ const history = useHistory(); const location = useLocation(); const [filterValue, setFilterValue] = useState(urls.getQueryStringFromLocation(location)); + const groupSelector = groups && (
    = ({ language = defaultLanguage, showLineNumb window.location.protocol + "//" + window.location.host + - withContextPath((permalink || location.pathname) + "#line-" + lineNumber); + urls.withContextPath((permalink || location.pathname) + "#line-" + lineNumber); const defaultRenderer = createSyntaxHighlighterRenderer(createLinePermaLink, showLineNumbers); diff --git a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx index 50fd4e4b49..c4a755d792 100644 --- a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx +++ b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx @@ -24,7 +24,7 @@ import React from "react"; import { SelectValue, AutocompleteObject } from "@scm-manager/ui-types"; import Autocomplete from "./Autocomplete"; -import { apiClient } from "./apiclient"; +import { apiClient } from "@scm-manager/ui-api"; export type AutocompleteProps = { autocompleteLink?: string; diff --git a/scm-ui/ui-components/src/__resources__/changesets.tsx b/scm-ui/ui-components/src/__resources__/changesets.tsx index 2743378ad4..4608f3cd84 100644 --- a/scm-ui/ui-components/src/__resources__/changesets.tsx +++ b/scm-ui/ui-components/src/__resources__/changesets.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { Changeset, PagedCollection } from "@scm-manager/ui-types"; +import {Changeset, ChangesetCollection, PagedCollection} from "@scm-manager/ui-types"; const one: Changeset = { id: "a88567ef1e9528a700555cad8c4576b72fc7c6dd", @@ -266,7 +266,7 @@ const five: Changeset = { } }; -const changesets: PagedCollection = { +const changesets: ChangesetCollection = { page: 0, pageTotal: 1, _links: { diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 7437e6b518..41f36697c9 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -1,58 +1,35 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Storyshots Annotate Default 1`] = ` -
    +Array [
    -
    -      
    -        
    - Arthur Dent +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 1
    - -
    - -
    -
    - 1 -
    - -
    - - - package - - main + + package + + + main + + + +
    +
    +
    +
    +
    + 2 +
    + +
    + + - +
    -
    -
    -
    - 2 -
    - -
    - +
    + Tricia Marie McMillan +
    + +
    + +
    + +
    +
    + 3 +
    + +
    + + + + + + import + + + + + + "fmt" + + + + + + +
    +
    +
    +
    +
    + 4 +
    + +
    + + + + +
    +
    +
    +
    +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 5 +
    + +
    + + + + + + func + + + + + + main + + + ( + + + ) + + + + + + { + + + + + + +
    +
    +
    +
    +
    + Ford Prefect +
    + +
    + +
    + +
    +
    + 6 +
    + +
    + + + fmt + + + . + + + Println + + + ( + + + "Hello World" + + + ) + + + + + + +
    +
    +
    +
    +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 7 +
    + +
    + + + + + + } + + + + + + +
    +
    +
    +
    +
    + 8 +
    + +
    + + + + +
    +
    + + + + +
    +
    +
    +
    , +