From 4407dc6d8a491d56078cd0c242516d96d9864c37 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Mar 2022 09:39:17 +0100 Subject: [PATCH] Add feedback form (#1967) Add feedback button and form. This feedback form can be used to provide direct feedback to the SCM-Manager Team. Co-authored-by: Matthias Thieroff --- gradle/changelog/feedback.yaml | 2 + .../sonia/scm/config/ScmConfiguration.java | 39 ++++++ scm-ui/ui-api/src/ApiProvider.test.tsx | 2 +- scm-ui/ui-api/src/LegacyContext.tsx | 1 - scm-ui/ui-api/src/config.test.ts | 15 +- scm-ui/ui-api/src/config.ts | 13 +- scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-types/src/Config.ts | 1 + scm-ui/ui-types/src/IndexResources.ts | 1 + .../ui-webapp/public/locales/de/commons.json | 4 + .../ui-webapp/public/locales/de/config.json | 2 + .../ui-webapp/public/locales/en/commons.json | 4 + .../ui-webapp/public/locales/en/config.json | 2 + .../src/admin/components/form/ConfigForm.tsx | 2 + .../admin/components/form/GeneralSettings.tsx | 16 +++ scm-ui/ui-webapp/src/containers/App.tsx | 2 + scm-ui/ui-webapp/src/containers/Feedback.tsx | 130 ++++++++++++++++++ scm-ui/ui-webapp/src/containers/Main.tsx | 1 - scm-ui/ui-webapp/src/containers/Theme.tsx | 2 +- .../sonia/scm/api/v2/resources/ConfigDto.java | 1 + .../sonia/scm/api/v2/resources/IndexDto.java | 8 +- .../api/v2/resources/IndexDtoGenerator.java | 7 +- 22 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 gradle/changelog/feedback.yaml create mode 100644 scm-ui/ui-webapp/src/containers/Feedback.tsx diff --git a/gradle/changelog/feedback.yaml b/gradle/changelog/feedback.yaml new file mode 100644 index 0000000000..c5e058c3f8 --- /dev/null +++ b/gradle/changelog/feedback.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add feedback button and form ([#1967](https://github.com/scm-manager/scm-manager/pull/1967)) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 72a14521da..57f76d3b65 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -85,6 +85,15 @@ public class ScmConfiguration implements Configuration { public static final String DEFAULT_ALERTS_URL = "https://alerts.scm-manager.org/api/v1/alerts"; + + /** + * SCM Manager alerts url. + * + * @since 2.32.0 + */ + public static final String DEFAULT_FEEDBACK_URL = + "https://response.cloudogu.com/api/v1/feedback/scm-manager/url"; + /** * SCM Manager release feed url */ @@ -181,6 +190,14 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "alerts-url") private String alertsUrl = DEFAULT_ALERTS_URL; + /** + * Url of the alerts api. + * + * @since 2.32.0 + */ + @XmlElement(name = "feedback-url") + private String feedbackUrl = DEFAULT_FEEDBACK_URL; + @XmlElement(name = "release-feed-url") private String releaseFeedUrl = DEFAULT_RELEASE_FEED_URL; @@ -288,6 +305,7 @@ public class ScmConfiguration implements Configuration { this.namespaceStrategy = other.namespaceStrategy; this.loginInfoUrl = other.loginInfoUrl; this.alertsUrl = other.alertsUrl; + this.feedbackUrl = other.feedbackUrl; this.releaseFeedUrl = other.releaseFeedUrl; this.mailDomainName = other.mailDomainName; this.emergencyContacts = other.emergencyContacts; @@ -378,6 +396,17 @@ public class ScmConfiguration implements Configuration { return alertsUrl; } + /** + * Returns the url of the feedback api. + * + * @return the feedback url. + * @since 2.32.0 + */ + public String getFeedbackUrl() { + return feedbackUrl; + } + + /** * Returns the url of the rss release feed. * @@ -622,6 +651,16 @@ public class ScmConfiguration implements Configuration { this.alertsUrl = alertsUrl; } + /** + * Set the url for the feedback api. + * + * @param feedbackUrl feedbackUrl url + * @since 2.32.0 + */ + public void setFeedbackUrl(String feedbackUrl) { + this.feedbackUrl = feedbackUrl; + } + public void setReleaseFeedUrl(String releaseFeedUrl) { this.releaseFeedUrl = releaseFeedUrl; } diff --git a/scm-ui/ui-api/src/ApiProvider.test.tsx b/scm-ui/ui-api/src/ApiProvider.test.tsx index f7dc216a67..4c76fac1ed 100644 --- a/scm-ui/ui-api/src/ApiProvider.test.tsx +++ b/scm-ui/ui-api/src/ApiProvider.test.tsx @@ -52,7 +52,7 @@ describe("ApiProvider tests", () => { }); if (result.current?.onIndexFetched) { - result.current.onIndexFetched({ version: "a.b.c", _links: {} }); + result.current.onIndexFetched({ version: "a.b.c", _links: {}, instanceId: "123" }); } expect(msg!).toEqual("hello"); diff --git a/scm-ui/ui-api/src/LegacyContext.tsx b/scm-ui/ui-api/src/LegacyContext.tsx index 1e61aadb1b..e3e6760407 100644 --- a/scm-ui/ui-api/src/LegacyContext.tsx +++ b/scm-ui/ui-api/src/LegacyContext.tsx @@ -33,7 +33,6 @@ export type BaseContext = { export type LegacyContext = BaseContext & { initialize: () => void; - queryClient?: QueryClient; }; const Context = createContext(undefined); diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts index 8b3bbb66c4..4081b5c453 100644 --- a/scm-ui/ui-api/src/config.test.ts +++ b/scm-ui/ui-api/src/config.test.ts @@ -58,13 +58,14 @@ describe("Test config hooks", () => { proxyUser: null, realmDescription: "", alertsUrl: "", + feedbackUrl: "", releaseFeedUrl: "", skipFailedAuthenticators: false, _links: { update: { - href: "/config", - }, - }, + href: "/config" + } + } }; afterEach(() => { @@ -77,7 +78,7 @@ describe("Test config hooks", () => { setIndexLink(queryClient, "config", "/config"); fetchMock.get("/api/v2/config", config); const { result, waitFor } = renderHook(() => useConfig(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => !!result.current.data); expect(result.current.data).toEqual(config); @@ -91,15 +92,15 @@ describe("Test config hooks", () => { const newConfig = { ...config, - baseUrl: "/hog", + baseUrl: "/hog" }; fetchMock.putOnce("/api/v2/config", { - status: 200, + status: 200 }); const { result, waitForNextUpdate } = renderHook(() => useUpdateConfig(), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await act(() => { diff --git a/scm-ui/ui-api/src/config.ts b/scm-ui/ui-api/src/config.ts index e16e233182..28826d30d5 100644 --- a/scm-ui/ui-api/src/config.ts +++ b/scm-ui/ui-api/src/config.ts @@ -30,20 +30,23 @@ import { requiredLink } from "./links"; export const useConfig = (): ApiResult => { const indexLink = useIndexLink("config"); - return useQuery("config", () => apiClient.get(indexLink!).then((response) => response.json()), { - enabled: !!indexLink, + 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) => { + config => { const updateUrl = requiredLink(config, "update"); return apiClient.put(updateUrl, config, "application/vnd.scmm-config+json;v=2"); }, { - onSuccess: () => queryClient.invalidateQueries("config"), + onSuccess: async () => { + await queryClient.invalidateQueries("config"); + await queryClient.invalidateQueries("index"); + } } ); return { @@ -51,6 +54,6 @@ export const useUpdateConfig = () => { isLoading, error, isUpdated: !!data, - reset, + reset }; }; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 5eac18ab82..2f4c795887 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -63,6 +63,7 @@ export * from "./search"; export * from "./loginInfo"; export * from "./usePluginCenterAuthInfo"; export * from "./compare"; +export * from "./utils"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index 8d94d83aa2..d7d1991b43 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -50,6 +50,7 @@ export type Config = HalRepresentation & { namespaceStrategy: string; loginInfoUrl: string; alertsUrl: string; + feedbackUrl: string; releaseFeedUrl: string; mailDomainName: string; emergencyContacts: string[]; diff --git a/scm-ui/ui-types/src/IndexResources.ts b/scm-ui/ui-types/src/IndexResources.ts index 32a92dc843..9838fd77f5 100644 --- a/scm-ui/ui-types/src/IndexResources.ts +++ b/scm-ui/ui-types/src/IndexResources.ts @@ -26,6 +26,7 @@ import { Embedded, Links } from "./hal"; export type IndexResources = { version: string; + instanceId: string; initialization?: string; _links: Links; _embedded?: Embedded; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 22588cf11d..de72e056be 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -176,6 +176,10 @@ "alerts": { "shieldTitle": "Alerts" }, + "feedback": { + "button": "Feedback", + "modalTitle": "Feedback senden" + }, "cardColumnGroup": { "showContent": "Inhalt einblenden", "hideContent": "Inhalt ausblenden" diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 394a956e44..80819dc651 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -68,6 +68,7 @@ }, "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", "alerts-url": "Alerts URL", + "feedback-url": "Feedback URL", "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback E-Mail Domain Name", "enabled-xsrf-protection": "XSRF Protection aktivieren", @@ -94,6 +95,7 @@ "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", "pluginAuthUrlHelpText": "Die URL der Plugin Center Authentifizierungs API.", "alertsUrlHelpText": "Die URL der Alerts API. Darüber wird über Alerts die Ihr System betreffen informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", + "feedbackUrlHelpText": "Die URL der Feedback API. Dies ermöglicht es Feedback direkt an das SCM-Manager Team zu senden. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "releaseFeedUrlHelpText": "Die URL des RSS Release Feed des SCM-Manager. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "mailDomainNameHelpText": "Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut.", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index df449379be..db964f10c7 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -177,6 +177,10 @@ "alerts": { "shieldTitle": "Alerts" }, + "feedback": { + "button": "Feedback", + "modalTitle": "Share your feedback" +}, "cardColumnGroup": { "showContent": "Show content", "hideContent": "Hide content" diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index e6d19a6d35..a4aca07770 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -68,6 +68,7 @@ }, "skip-failed-authenticators": "Skip Failed Authenticators", "alerts-url": "Alerts URL", + "feedback-url": "Feedback URL", "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback Mail Domain Name", "enabled-xsrf-protection": "Enabled XSRF Protection", @@ -94,6 +95,7 @@ "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", "pluginAuthUrlHelpText": "The url of the Plugin Center authentication API.", "alertsUrlHelpText": "The url of the alerts api. This provides up-to-date alerts regarding your system. To disable this feature just leave the url blank.", + "feedbackUrlHelpText": "The url of the feedback api. This can be used to send feedback to the SCM-Manager team. To disable this feature just leave the url blank.", "releaseFeedUrlHelpText": "The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank.", "mailDomainNameHelpText": "This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise.", "enableForwardingHelpText": "Enable mod_proxy port forwarding.", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index 63f55a2fa4..9d34d00afa 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -73,6 +73,7 @@ const ConfigForm: FC = ({ namespaceStrategy: "", loginInfoUrl: "", alertsUrl: "", + feedbackUrl: "", releaseFeedUrl: "", mailDomainName: "", emergencyContacts: [], @@ -153,6 +154,7 @@ const ConfigForm: FC = ({ enabledApiKeys={innerConfig.enabledApiKeys} emergencyContacts={innerConfig.emergencyContacts} namespaceStrategy={innerConfig.namespaceStrategy} + feedbackUrl={innerConfig.feedbackUrl} onChange={onChange} hasUpdatePermission={configUpdatePermission} /> diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index ad5f2de91b..40c1234ee4 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -42,6 +42,7 @@ type Props = { anonymousMode: AnonymousMode; skipFailedAuthenticators: boolean; alertsUrl: string; + feedbackUrl: string; releaseFeedUrl: string; mailDomainName: string; enabledXsrfProtection: boolean; @@ -59,6 +60,7 @@ const GeneralSettings: FC = ({ loginInfoUrl, anonymousMode, alertsUrl, + feedbackUrl, releaseFeedUrl, mailDomainName, enabledXsrfProtection, @@ -94,6 +96,9 @@ const GeneralSettings: FC = ({ const handleAlertsUrlChange = (value: string) => { onChange(true, value, "alertsUrl"); }; + const handleFeedbackUrlChange = (value: string) => { + onChange(true, value, "feedbackUrl"); + }; const handleReleaseFeedUrlChange = (value: string) => { onChange(true, value, "releaseFeedUrl"); }; @@ -231,6 +236,17 @@ const GeneralSettings: FC = ({ /> +
+
+ +
+
{ return ( + {authenticated ? : null}
diff --git a/scm-ui/ui-webapp/src/containers/Feedback.tsx b/scm-ui/ui-webapp/src/containers/Feedback.tsx new file mode 100644 index 0000000000..ddfdf178ab --- /dev/null +++ b/scm-ui/ui-webapp/src/containers/Feedback.tsx @@ -0,0 +1,130 @@ +/* + * 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, useMemo, useState } from "react"; +import styled from "styled-components"; +import { apiClient, Button, Modal } from "@scm-manager/ui-components"; +import { HalRepresentation, IndexResources, Link } from "@scm-manager/ui-types"; +import { useTranslation } from "react-i18next"; +import { ApiResult, createQueryString } from "@scm-manager/ui-api"; +import { useQuery } from "react-query"; +import { useThemeState } from "./Theme"; + +type Props = { + index: IndexResources; +}; + +const useFeedbackUrl = (url: string): ApiResult => + useQuery(["config", "feedback"], () => apiClient.get(url).then(r => r.json()), { + refetchOnWindowFocus: false + }); + +const createFeedbackFormUrl = (instanceId: string, scmVersion: string, theme: string, data?: HalRepresentation) => { + if (data) { + const formUrl = (data?._links.form as Link).href; + return `${formUrl}?${createQueryString({ instanceId, scmVersion, theme })}`; + } + return ""; +}; + +const useFeedback = (index: IndexResources) => { + const feedbackUrl = (index._links.feedback as Link)?.href || ""; + const { theme } = useThemeState(); + const { data, error, isLoading } = useFeedbackUrl(feedbackUrl); + const formUrl = useMemo(() => createFeedbackFormUrl(index.instanceId, index.version, theme, data), [ + theme, + data, + index.instanceId, + index.version + ]); + + if (!index._links.feedback || error || isLoading || !formUrl) { + return { + isAvailable: false, + formUrl: "" + }; + } + + return { + isAvailable: true, + formUrl + }; +}; + +const Feedback: FC = ({ index }) => { + const { isAvailable, formUrl } = useFeedback(index); + const [showModal, setShowModal] = useState(false); + + if (isAvailable && !showModal) { + return setShowModal(true)} />; + } + if (showModal) { + return setShowModal(false)} formUrl={formUrl} />; + } + + return null; +}; + +const TriggerButton = styled(Button)` + position: fixed; + z-index: 9999999; + right: 1rem; + bottom: -1px; + border-radius: 0.2rem 0.2rem 0 0; +`; + +const ModalWrapper = styled(Modal)` + .modal-card-body { + padding: 0; + } +`; + +const FeedbackTriggerButton: FC<{ openModal: () => void }> = ({ openModal }) => { + const [t] = useTranslation("commons"); + return ; +}; + +type FormProps = { + close: () => void; + formUrl: string; +}; + +const FeedbackWrapper = styled.div` + height: 45rem; + width: auto; +`; + +const FeedbackForm: FC = ({ close, formUrl }) => { + const [t] = useTranslation("commons"); + + return ( + + +