diff --git a/scm-ui/ui-api/src/configLink.test.ts b/scm-ui/ui-api/src/configLink.test.ts new file mode 100644 index 0000000000..ba838d412f --- /dev/null +++ b/scm-ui/ui-api/src/configLink.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { HalRepresentation } from "@scm-manager/ui-types"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { useConfigLink } from "./configLink"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { act } from "react-test-renderer"; + +describe("useConfigLink tests", () => { + type MyConfig = HalRepresentation & { + name: string; + }; + + const myReadOnlyConfig: MyConfig = { + name: "Hansolo", + _links: {}, + }; + + const myConfig: MyConfig = { + name: "Lea", + _links: { + update: { + href: "/my/config-write", + }, + }, + }; + + afterEach(() => { + fetchMock.reset(); + }); + + const fetchConfiguration = async (config: MyConfig) => { + fetchMock.getOnce("/api/v2/my/config", config); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useConfigLink("/my/config"), { + wrapper: createWrapper(undefined, queryClient), + }); + await waitFor(() => { + return !!result.current.initialConfiguration; + }); + + return result.current; + }; + + it("should return read only configuration without update link", async () => { + const { isReadOnly } = await fetchConfiguration(myReadOnlyConfig); + + expect(isReadOnly).toBe(true); + }); + + it("should not be read only with update link", async () => { + const { isReadOnly } = await fetchConfiguration(myConfig); + + expect(isReadOnly).toBe(false); + }); + + it("should call update url", async () => { + const queryClient = createInfiniteCachingClient(); + + fetchMock.get("/api/v2/my/config", myConfig); + + const { result, waitFor, waitForNextUpdate } = renderHook(() => useConfigLink("/my/config"), { + wrapper: createWrapper(undefined, queryClient), + }); + + await waitFor(() => { + return !!result.current.initialConfiguration; + }); + + const { update } = result.current; + + const lukesConfig = { + ...myConfig, + name: "Luke", + }; + + fetchMock.putOnce( + { + url: "/api/v2/my/config-write", + headers: { + "Content-Type": "application/json", + }, + body: lukesConfig, + }, + { + status: 204, + } + ); + + await act(() => { + update(lukesConfig); + return waitForNextUpdate(); + }); + + expect(result.current.isUpdated).toBe(true); + }); + + it("should capture content type update url", async () => { + const queryClient = createInfiniteCachingClient(); + + fetchMock.get("/api/v2/my/config", { + headers: { + "Content-Type": "application/myconfig+json", + }, + body: myConfig, + }); + + const { result, waitFor, waitForNextUpdate } = renderHook(() => useConfigLink("/my/config"), { + wrapper: createWrapper(undefined, queryClient), + }); + + await waitFor(() => { + return !!result.current.initialConfiguration; + }); + + const { update } = result.current; + + const lukesConfig = { + ...myConfig, + name: "Luke", + }; + + fetchMock.putOnce( + { + url: "/api/v2/my/config-write", + headers: { + "Content-Type": "application/myconfig+json", + }, + body: lukesConfig, + }, + { + status: 204, + } + ); + + await act(() => { + update(lukesConfig); + return waitForNextUpdate(); + }); + + expect(result.current.isUpdated).toBe(true); + }); +}); diff --git a/scm-ui/ui-api/src/configLink.ts b/scm-ui/ui-api/src/configLink.ts new file mode 100644 index 0000000000..22bfd50917 --- /dev/null +++ b/scm-ui/ui-api/src/configLink.ts @@ -0,0 +1,89 @@ +/* + * 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 { useMutation, useQuery, useQueryClient } from "react-query"; +import { apiClient } from "./apiclient"; +import { useCallback } from "react"; +import { HalRepresentation, Link } from "@scm-manager/ui-types"; + +type Result = { + contentType: string; + configuration: C; +}; + +type MutationVariables = { + configuration: C; + contentType: string; + link: string; +}; + +export const useConfigLink = (link: string) => { + const queryClient = useQueryClient(); + const queryKey = ["configLink", link]; + const { isLoading, error, data } = useQuery, Error>(queryKey, () => + apiClient.get(link).then((response) => { + const contentType = response.headers.get("Content-Type") || "application/json"; + return response.json().then((configuration: C) => ({ configuration, contentType })); + }) + ); + + const { + isLoading: isUpdating, + error: mutationError, + mutate, + data: updateResponse, + } = useMutation>( + (vars: MutationVariables) => apiClient.put(vars.link, vars.configuration, vars.contentType), + { + onSuccess: async () => { + await queryClient.invalidateQueries(queryKey); + }, + } + ); + + const isReadOnly = !data?.configuration._links.update; + + const update = useCallback( + (configuration: C) => { + if (data && !isReadOnly) { + mutate({ + configuration, + contentType: data.contentType, + link: (data.configuration._links.update as Link).href, + }); + } + }, + [mutate, data, isReadOnly] + ); + + return { + isLoading, + isUpdating, + isReadOnly, + error: error || mutationError, + initialConfiguration: data?.configuration, + update, + isUpdated: !!updateResponse, + }; +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index f3c3b420f5..aec6bfe796 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -47,6 +47,7 @@ export * from "./sources"; export * from "./import"; export * from "./diff"; export * from "./notifications"; +export * from "./configLink"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-components/src/config/Configuration.tsx b/scm-ui/ui-components/src/config/Configuration.tsx index c3f891e033..3bdf72ba66 100644 --- a/scm-ui/ui-components/src/config/Configuration.tsx +++ b/scm-ui/ui-components/src/config/Configuration.tsx @@ -21,199 +21,51 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FormEvent } from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; -import { Links, Link } from "@scm-manager/ui-types"; -import { apiClient, Level, SubmitButton, Loading, ErrorNotification } from "../"; +import React, { FC, FormEvent, useState } from "react"; +import { HalRepresentation } from "@scm-manager/ui-types"; +import { useConfigLink } from "@scm-manager/ui-api"; +import ConfigurationForm from "./ConfigurationForm"; type RenderProps = { readOnly: boolean; - initialConfiguration: ConfigurationType; - onConfigurationChange: (p1: ConfigurationType, p2: boolean) => void; + initialConfiguration: HalRepresentation; + onConfigurationChange: (configuration: HalRepresentation, valid: boolean) => void; }; -type Props = WithTranslation & { +type Props = { link: string; render: (props: RenderProps) => any; // ??? }; -type ConfigurationType = { - _links: Links; -} & object; +const Configuration: FC = ({ link, render }) => { + const { initialConfiguration, update, ...formProps } = useConfigLink(link); + const [configuration, setConfiguration] = useState(); + const [isValid, setIsValid] = useState(false); -type State = { - error?: Error; - fetching: boolean; - modifying: boolean; - contentType?: string | null; - configChanged: boolean; + const onConfigurationChange = (config: HalRepresentation, valid: boolean) => { + setConfiguration(config); + setIsValid(valid); + }; - configuration?: ConfigurationType; - modifiedConfiguration?: ConfigurationType; - valid: boolean; + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + + if (configuration) { + update(configuration); + } + }; + + return ( + + {initialConfiguration + ? render({ + readOnly: formProps.isReadOnly, + initialConfiguration, + onConfigurationChange, + }) + : null} + + ); }; -/** - * GlobalConfiguration uses the render prop pattern to encapsulate the logic for - * synchronizing the configuration with the backend. - */ -class Configuration extends React.Component { - constructor(props: Props) { - super(props); - this.state = { - fetching: true, - modifying: false, - configChanged: false, - valid: false - }; - } - - componentDidMount() { - const { link } = this.props; - - apiClient - .get(link) - .then(this.captureContentType) - .then(response => response.json()) - .then(this.loadConfig) - .catch(this.handleError); - } - - captureContentType = (response: Response) => { - const contentType = response.headers.get("Content-Type"); - this.setState({ - contentType - }); - return response; - }; - - getContentType = (): string => { - const { contentType } = this.state; - return contentType ? contentType : "application/json"; - }; - - handleError = (error: Error) => { - this.setState({ - error, - fetching: false, - modifying: false - }); - }; - - loadConfig = (configuration: ConfigurationType) => { - this.setState({ - configuration, - fetching: false, - error: undefined - }); - }; - - getModificationUrl = (): string | undefined => { - const { configuration } = this.state; - if (configuration) { - const links = configuration._links; - if (links && links.update) { - const link = links.update as Link; - return link.href; - } - } - }; - - isReadOnly = (): boolean => { - const modificationUrl = this.getModificationUrl(); - return !modificationUrl; - }; - - configurationChanged = (configuration: ConfigurationType, valid: boolean) => { - this.setState({ - modifiedConfiguration: configuration, - valid - }); - }; - - modifyConfiguration = (event: FormEvent) => { - event.preventDefault(); - - this.setState({ - modifying: true, - error: undefined - }); - - const { modifiedConfiguration } = this.state; - - const modificationUrl = this.getModificationUrl(); - if (modificationUrl) { - apiClient - .put(modificationUrl, modifiedConfiguration, this.getContentType()) - .then(() => - this.setState({ - modifying: false, - configChanged: true, - valid: false - }) - ) - .catch(this.handleError); - } else { - this.setState({ - error: new Error("no modification link available") - }); - } - }; - - renderConfigChangedNotification = () => { - if (this.state.configChanged) { - return ( -
-
- ); - } - return null; - }; - - render() { - const { t } = this.props; - const { fetching, error, configuration, modifying, valid } = this.state; - - if (fetching || !configuration) { - return ; - } else { - const readOnly = this.isReadOnly(); - - const renderProps: RenderProps = { - readOnly, - initialConfiguration: configuration, - onConfigurationChange: this.configurationChanged - }; - - return ( - <> - {this.renderConfigChangedNotification()} -
- {this.props.render(renderProps)} - {error && ( - <> -
- - - )} -
- } - /> - - - ); - } - } -} - -export default withTranslation("config")(Configuration); +export default Configuration; diff --git a/scm-ui/ui-components/src/config/ConfigurationForm.tsx b/scm-ui/ui-components/src/config/ConfigurationForm.tsx new file mode 100644 index 0000000000..e279527c83 --- /dev/null +++ b/scm-ui/ui-components/src/config/ConfigurationForm.tsx @@ -0,0 +1,92 @@ +/* + * 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, FormEvent, useEffect, useState } from "react"; +import { ErrorNotification, Level, SubmitButton } from "../index"; +import { useTranslation } from "react-i18next"; +import Loading from "../Loading"; + +type Props = { + isUpdating: boolean; + isUpdated: boolean; + isReadOnly: boolean; + isLoading: boolean; + isValid: boolean; + error?: Error | null; + onSubmit: (e: FormEvent) => void; +}; + +const ConfigurationChangedNotification: FC<{ configChanged: boolean }> = ({ configChanged }) => { + const [t] = useTranslation("config"); + const [hide, setHide] = useState(false); + useEffect(() => { + setHide(false); + }, [configChanged]); + + if (!configChanged || hide) { + return null; + } + return ( +
+
+ ); +}; + +const ConfigurationForm: FC = ({ + isLoading, + isReadOnly, + isValid, + isUpdating, + isUpdated, + error, + onSubmit, + children, +}) => { + const [t] = useTranslation("config"); + + if (isLoading) { + return ; + } + + return ( +
+ + {children} + {error && ( + <> +
+ + + )} +
+ } + /> + + ); +}; + +export default ConfigurationForm; diff --git a/scm-ui/ui-components/src/config/index.ts b/scm-ui/ui-components/src/config/index.ts index 4dc023a108..0d4d21eb68 100644 --- a/scm-ui/ui-components/src/config/index.ts +++ b/scm-ui/ui-components/src/config/index.ts @@ -24,3 +24,4 @@ export { default as ConfigurationBinder } from "./ConfigurationBinder"; export { default as Configuration } from "./Configuration"; +export { default as ConfigurationForm } from "./ConfigurationForm";