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 <matthias.thieroff@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2022-03-10 09:39:17 +01:00
committed by GitHub
parent 390384b723
commit 4407dc6d8a
22 changed files with 235 additions and 21 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Add feedback button and form ([#1967](https://github.com/scm-manager/scm-manager/pull/1967))

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -33,7 +33,6 @@ export type BaseContext = {
export type LegacyContext = BaseContext & {
initialize: () => void;
queryClient?: QueryClient;
};
const Context = createContext<LegacyContext | undefined>(undefined);

View File

@@ -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(() => {

View File

@@ -30,20 +30,23 @@ import { requiredLink } from "./links";
export const useConfig = (): ApiResult<Config> => {
const indexLink = useIndexLink("config");
return useQuery<Config, Error>("config", () => apiClient.get(indexLink!).then((response) => response.json()), {
enabled: !!indexLink,
return useQuery<Config, Error>("config", () => apiClient.get(indexLink!).then(response => response.json()), {
enabled: !!indexLink
});
};
export const useUpdateConfig = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data, reset } = useMutation<unknown, Error, Config>(
(config) => {
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
};
};

View File

@@ -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";

View File

@@ -50,6 +50,7 @@ export type Config = HalRepresentation & {
namespaceStrategy: string;
loginInfoUrl: string;
alertsUrl: string;
feedbackUrl: string;
releaseFeedUrl: string;
mailDomainName: string;
emergencyContacts: string[];

View File

@@ -26,6 +26,7 @@ import { Embedded, Links } from "./hal";
export type IndexResources = {
version: string;
instanceId: string;
initialization?: string;
_links: Links;
_embedded?: Embedded;

View File

@@ -176,6 +176,10 @@
"alerts": {
"shieldTitle": "Alerts"
},
"feedback": {
"button": "Feedback",
"modalTitle": "Feedback senden"
},
"cardColumnGroup": {
"showContent": "Inhalt einblenden",
"hideContent": "Inhalt ausblenden"

View File

@@ -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.",

View File

@@ -177,6 +177,10 @@
"alerts": {
"shieldTitle": "Alerts"
},
"feedback": {
"button": "Feedback",
"modalTitle": "Share your feedback"
},
"cardColumnGroup": {
"showContent": "Show content",
"hideContent": "Hide content"

View File

@@ -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.",

View File

@@ -73,6 +73,7 @@ const ConfigForm: FC<Props> = ({
namespaceStrategy: "",
loginInfoUrl: "",
alertsUrl: "",
feedbackUrl: "",
releaseFeedUrl: "",
mailDomainName: "",
emergencyContacts: [],
@@ -153,6 +154,7 @@ const ConfigForm: FC<Props> = ({
enabledApiKeys={innerConfig.enabledApiKeys}
emergencyContacts={innerConfig.emergencyContacts}
namespaceStrategy={innerConfig.namespaceStrategy}
feedbackUrl={innerConfig.feedbackUrl}
onChange={onChange}
hasUpdatePermission={configUpdatePermission}
/>

View File

@@ -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<Props> = ({
loginInfoUrl,
anonymousMode,
alertsUrl,
feedbackUrl,
releaseFeedUrl,
mailDomainName,
enabledXsrfProtection,
@@ -94,6 +96,9 @@ const GeneralSettings: FC<Props> = ({
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<Props> = ({
/>
</div>
</div>
<div className="columns">
<div className="column is-full">
<InputField
label={t("general-settings.feedback-url")}
onChange={handleFeedbackUrlChange}
value={feedbackUrl}
disabled={!hasUpdatePermission}
helpText={t("help.feedbackUrlHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-full">
<MemberNameTagGroup

View File

@@ -30,6 +30,7 @@ import Login from "./Login";
import { useIndex, useSubject } from "@scm-manager/ui-api";
import NavigationBar from "./NavigationBar";
import styled from "styled-components";
import Feedback from "./Feedback";
const AppWrapper = styled.div`
min-height: 100vh;
@@ -65,6 +66,7 @@ const App: FC = () => {
return (
<AppWrapper className="App">
{authenticated ? <Feedback index={index} /> : null}
<Header authenticated={authenticated} links={index._links}>
<NavigationBar links={index._links} />
</Header>

View File

@@ -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<HalRepresentation> =>
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<Props> = ({ index }) => {
const { isAvailable, formUrl } = useFeedback(index);
const [showModal, setShowModal] = useState(false);
if (isAvailable && !showModal) {
return <FeedbackTriggerButton openModal={() => setShowModal(true)} />;
}
if (showModal) {
return <FeedbackForm close={() => 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 <TriggerButton action={openModal} color="info" label={t("feedback.button")} icon="comment" />;
};
type FormProps = {
close: () => void;
formUrl: string;
};
const FeedbackWrapper = styled.div`
height: 45rem;
width: auto;
`;
const FeedbackForm: FC<FormProps> = ({ close, formUrl }) => {
const [t] = useTranslation("commons");
return (
<ModalWrapper title={t("feedback.modalTitle")} active={true} closeFunction={close}>
<FeedbackWrapper>
<iframe src={formUrl} height="100%" width="100%" title="feedback-form" />
</FeedbackWrapper>
</ModalWrapper>
);
};
export default Feedback;

View File

@@ -57,7 +57,6 @@ const Search = React.lazy(() => import("../search/Search"));
const Syntax = React.lazy(() => import("../search/Syntax"));
const ExternalError = React.lazy(() => import("./ExternalError"));
type Props = {
me: Me;
authenticated?: boolean;

View File

@@ -31,7 +31,7 @@ import classNames from "classnames";
const LS_KEY = "scm.theme";
const useThemeState = () => {
export const useThemeState = () => {
const [theme] = useState(localStorage.getItem(LS_KEY) || "systemdefault");
const [isLoading, setLoading] = useState(false);

View File

@@ -62,6 +62,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
private String namespaceStrategy;
private String loginInfoUrl;
private String alertsUrl;
private String feedbackUrl;
private String releaseFeedUrl;
private String mailDomainName;
private Set<String> emergencyContacts;

View File

@@ -34,17 +34,19 @@ import lombok.Getter;
public class IndexDto extends HalRepresentation {
private final String version;
private final String instanceId;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private final String initialization;
IndexDto(Links links, Embedded embedded, String version) {
this(links, embedded, version, null);
IndexDto(Links links, Embedded embedded, String version, String instanceId) {
this(links, embedded, version, instanceId, null);
}
IndexDto(Links links, Embedded embedded, String version, String initialization) {
IndexDto(Links links, Embedded embedded, String version, String instanceId, String initialization) {
super(links, embedded);
this.version = version;
this.instanceId = instanceId;
this.initialization = initialization;
}
}

View File

@@ -150,12 +150,15 @@ public class IndexDtoGenerator extends HalAppenderMapper {
if (!Strings.isNullOrEmpty(configuration.getAlertsUrl())) {
builder.single(link("alerts", resourceLinks.alerts().get()));
}
if (!Strings.isNullOrEmpty(configuration.getFeedbackUrl())) {
builder.single(link("feedback", configuration.getFeedbackUrl()));
}
} else {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}
applyEnrichers(new EdisonHalAppender(builder, embeddedBuilder), new Index());
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), scmContextProvider.getInstanceId());
}
private List<Link> searchLinks() {
@@ -173,7 +176,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
InitializationStep initializationStep = initializationFinisher.missingInitialization();
initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder);
embeddedBuilder.with(initializationStep.name(), new InitializationDto(initializationLinkBuilder.build(), initializationEmbeddedBuilder.build()));
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), initializationStep.name());
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), scmContextProvider.getInstanceId(), initializationStep.name());
}
private boolean shouldAppendSubjectRelatedLinks() {