) => {
+ setForm(
+ produce(draft => {
+ draft.score = Number(event.target.value);
+ })
+ );
+ };
+
+ return (
+
+ Very difficult
+ {[1, 2, 3, 4, 5, 6, 7].map(score => (
+
+ ))}
+ Very easy
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap b/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap
new file mode 100644
index 0000000000..8be1647ba3
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap
@@ -0,0 +1,222 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackCESForm 1`] = `
+
+
+ Please help us improve
+
+
+
+`;
diff --git a/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts b/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts
new file mode 100644
index 0000000000..f1f328b4f0
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts
@@ -0,0 +1,40 @@
+import { IFeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
+
+interface IFeedbackEndpointRequestBody {
+ source: 'app' | 'app:segments';
+ data: {
+ score: number;
+ comment?: string;
+ customerType?: 'open source' | 'paying';
+ openedManually?: boolean;
+ currentPage?: string;
+ };
+}
+
+export const sendFeedbackInput = async (
+ form: Partial
+): Promise => {
+ if (!form.score) {
+ return;
+ }
+
+ const body: IFeedbackEndpointRequestBody = {
+ source: 'app:segments',
+ data: {
+ score: form.score,
+ comment: form.comment,
+ currentPage: form.path,
+ openedManually: false,
+ customerType: 'paying',
+ },
+ };
+
+ await fetch(
+ 'https://europe-west3-docs-feedback-v1.cloudfunctions.net/function-1',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts
new file mode 100644
index 0000000000..3750c9e0bc
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts
@@ -0,0 +1,29 @@
+import React, { createContext } from 'react';
+
+export type ShowFeedbackCES = React.Dispatch<
+ React.SetStateAction
+>;
+
+export interface IFeedbackCESContext {
+ showFeedbackCES: ShowFeedbackCES;
+ hideFeedbackCES: () => void;
+}
+
+export interface IFeedbackCESState {
+ path: `/${string}`;
+ title: string;
+ text: string;
+}
+
+const showFeedbackCESPlaceholder = () => {
+ throw new Error('showFeedbackCES called outside feedbackCESContext');
+};
+
+const hideFeedbackCESPlaceholder = () => {
+ throw new Error('hideFeedbackCES called outside feedbackCESContext');
+};
+
+export const feedbackCESContext = createContext({
+ showFeedbackCES: showFeedbackCESPlaceholder,
+ hideFeedbackCES: hideFeedbackCESPlaceholder,
+});
diff --git a/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx
new file mode 100644
index 0000000000..7a90954478
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx
@@ -0,0 +1,60 @@
+import React, {
+ useState,
+ ReactNode,
+ useMemo,
+ useCallback,
+ useEffect,
+} from 'react';
+import { FeedbackCES } from 'component/feedback/FeedbackCES/FeedbackCES';
+import {
+ feedbackCESContext,
+ ShowFeedbackCES,
+ IFeedbackCESContext,
+ IFeedbackCESState,
+} from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { useFeedbackCESSeen } from 'component/feedback/FeedbackCESContext/useFeedbackCESSeen';
+import { useFeedbackCESEnabled } from 'component/feedback/FeedbackCESContext/useFeedbackCESEnabled';
+
+interface IFeedbackProviderProps {
+ children: ReactNode;
+}
+
+export const FeedbackCESProvider = ({ children }: IFeedbackProviderProps) => {
+ const [state, setState] = useState();
+ const { isSeen, setSeen } = useFeedbackCESSeen();
+ const enabled = useFeedbackCESEnabled();
+
+ useEffect(() => {
+ state && setSeen(state);
+ }, [state, setSeen]);
+
+ // Set a new feedback state iff the path is unseen and CES is enabled.
+ const showFeedbackCES: ShowFeedbackCES = useCallback(
+ value => {
+ setState(prev => {
+ const next = value instanceof Function ? value(prev) : value;
+ return !enabled || !next || isSeen(next) ? undefined : next;
+ });
+ },
+ [enabled, isSeen]
+ );
+
+ const hideFeedbackCES = useCallback(() => {
+ setState(undefined);
+ }, [setState]);
+
+ const value: IFeedbackCESContext = useMemo(
+ () => ({
+ showFeedbackCES: showFeedbackCES,
+ hideFeedbackCES: hideFeedbackCES,
+ }),
+ [showFeedbackCES, hideFeedbackCES]
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts
new file mode 100644
index 0000000000..84a79fe21d
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts
@@ -0,0 +1,10 @@
+export const useFeedbackCESEnabled = (): boolean => {
+ const { hostname } = window.location;
+
+ return (
+ hostname === 'localhost' ||
+ hostname.endsWith('.vercel.app') ||
+ hostname.endsWith('.getunleash.io') ||
+ hostname.endsWith('.unleash-hosted.com')
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts
new file mode 100644
index 0000000000..7c0b906f03
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts
@@ -0,0 +1,36 @@
+import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
+import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
+import { IFeedbackCESState } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { useCallback } from 'react';
+
+interface IUseFeedbackCESSeen {
+ setSeen: (state: IFeedbackCESState) => void;
+ isSeen: (state: IFeedbackCESState) => boolean;
+}
+
+export const useFeedbackCESSeen = (): IUseFeedbackCESSeen => {
+ const { createFeedback } = useAuthFeedbackApi();
+ const { feedback } = useAuthFeedback();
+
+ const isSeen = useCallback(
+ (state: IFeedbackCESState) =>
+ !!feedback &&
+ feedback.some(f => f.feedbackId === formatFeedbackCESId(state)),
+ [feedback]
+ );
+
+ const setSeen = useCallback(
+ (state: IFeedbackCESState) =>
+ createFeedback({ feedbackId: formatFeedbackCESId(state) }),
+ [createFeedback]
+ );
+
+ return {
+ isSeen,
+ setSeen,
+ };
+};
+
+const formatFeedbackCESId = (state: IFeedbackCESState): string => {
+ return `ces${state.path}`;
+};
diff --git a/frontend/src/component/common/Feedback/Feedback.styles.ts b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.styles.ts
similarity index 100%
rename from frontend/src/component/common/Feedback/Feedback.styles.ts
rename to frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.styles.ts
diff --git a/frontend/src/component/common/Feedback/Feedback.tsx b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx
similarity index 75%
rename from frontend/src/component/common/Feedback/Feedback.tsx
rename to frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx
index a8f92ee1fc..a9ac5d3563 100644
--- a/frontend/src/component/common/Feedback/Feedback.tsx
+++ b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx
@@ -4,44 +4,37 @@ import classnames from 'classnames';
import CloseIcon from '@material-ui/icons/Close';
import { ReactComponent as Logo } from 'assets/icons/logoPlain.svg';
import { useCommonStyles } from 'common.styles';
-import { useStyles } from './Feedback.styles';
-import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
-import ConditionallyRender from '../ConditionallyRender';
-import { formatApiPath } from 'utils/formatPath';
+import { useStyles } from 'component/feedback/FeedbackNPS/FeedbackNPS.styles';
+import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
+import ConditionallyRender from 'component/common/ConditionallyRender';
import UIContext from 'contexts/UIContext';
-import { PNPS_FEEDBACK_ID, showPnpsFeedback } from '../util';
+import {
+ PNPS_FEEDBACK_ID,
+ showNPSFeedback,
+} from 'component/feedback/FeedbackNPS/showNPSFeedback';
import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
+import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
-interface IFeedbackProps {
+interface IFeedbackNPSProps {
openUrl: string;
}
-const Feedback = ({ openUrl }: IFeedbackProps) => {
+export const FeedbackNPS = ({ openUrl }: IFeedbackNPSProps) => {
const { showFeedback, setShowFeedback } = useContext(UIContext);
- const { feedback, refetchFeedback } = useAuthFeedback();
+ const { createFeedback, updateFeedback } = useAuthFeedbackApi();
+ const { feedback } = useAuthFeedback();
const [answeredNotNow, setAnsweredNotNow] = useState(false);
const styles = useStyles();
const commonStyles = useCommonStyles();
const feedbackId = PNPS_FEEDBACK_ID;
const onConfirm = async () => {
- const url = formatApiPath('api/admin/feedback');
-
try {
- await fetch(url, {
- method: 'POST',
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ feedbackId }),
- });
- await refetchFeedback();
+ await createFeedback({ feedbackId });
} catch (err) {
console.warn(err);
setShowFeedback(false);
}
-
// Await api call to register confirmation
window.open(openUrl, '_blank');
setTimeout(() => {
@@ -50,32 +43,18 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
};
const onDontShowAgain = async () => {
- const feedbackId = 'pnps';
- const url = formatApiPath(
- `api/admin/feedback/${encodeURIComponent(feedbackId)}`
- );
-
try {
- await fetch(url, {
- method: 'PUT',
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ feedbackId, neverShow: true }),
- });
- await refetchFeedback();
+ await updateFeedback({ feedbackId, neverShow: true });
} catch (err) {
console.warn(err);
setShowFeedback(false);
}
-
setTimeout(() => {
setShowFeedback(false);
}, 100);
};
- if (!showPnpsFeedback(feedback)) {
+ if (!showNPSFeedback(feedback)) {
return null;
}
@@ -152,5 +131,3 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
);
};
-
-export default Feedback;
diff --git a/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts b/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts
new file mode 100644
index 0000000000..50b0cb2475
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts
@@ -0,0 +1,37 @@
+import differenceInDays from 'date-fns/differenceInDays';
+import { IAuthFeedback } from 'hooks/api/getters/useAuth/useAuthEndpoint';
+
+export const PNPS_FEEDBACK_ID = 'pnps';
+
+export const showNPSFeedback = (
+ feedbackList: IAuthFeedback[] | undefined
+): boolean => {
+ if (!feedbackList) {
+ return false;
+ }
+
+ if (feedbackList.length === 0) {
+ return true;
+ }
+
+ const feedback = feedbackList.find(
+ feedback => feedback.feedbackId === PNPS_FEEDBACK_ID
+ );
+
+ if (!feedback) {
+ return true;
+ }
+
+ if (feedback.neverShow) {
+ return false;
+ }
+
+ if (feedback.given) {
+ const SIX_MONTHS_IN_DAYS = 182;
+ const now = new Date();
+ const difference = differenceInDays(now, new Date(feedback.given));
+ return difference > SIX_MONTHS_IN_DAYS;
+ }
+
+ return false;
+};
diff --git a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx
index f492c700dc..80e4755366 100644
--- a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx
+++ b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx
@@ -6,15 +6,17 @@ import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValida
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
-import React from 'react';
+import React, { useContext } from 'react';
import { useHistory } from 'react-router-dom';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useSegmentForm } from '../hooks/useSegmentForm';
import { SegmentForm } from '../SegmentForm/SegmentForm';
+import { feedbackCESContext } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
export const CreateSegment = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
+ const { showFeedbackCES } = useContext(feedbackCESContext);
const history = useHistory();
const { createSegment, loading } = useSegmentsApi();
const { refetchSegments } = useSegments();
@@ -54,6 +56,11 @@ export const CreateSegment = () => {
confetti: true,
type: 'success',
});
+ showFeedbackCES({
+ title: 'How easy was it to create a segment?',
+ text: 'Please help us understand how we can improve segments',
+ path: '/segments/create',
+ });
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
diff --git a/frontend/src/hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi.ts b/frontend/src/hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi.ts
new file mode 100644
index 0000000000..a14fd0e781
--- /dev/null
+++ b/frontend/src/hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi.ts
@@ -0,0 +1,48 @@
+import { formatApiPath } from 'utils/formatPath';
+import { useCallback } from 'react';
+import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
+import { IAuthFeedback } from 'hooks/api/getters/useAuth/useAuthEndpoint';
+
+interface IUseAuthFeedbackApi {
+ createFeedback: (feedback: IAuthFeedback) => Promise;
+ updateFeedback: (feedback: IAuthFeedback) => Promise;
+}
+
+export const useAuthFeedbackApi = (): IUseAuthFeedbackApi => {
+ const { refetchFeedback } = useAuthFeedback();
+ const path = formatApiPath('api/admin/feedback');
+
+ const createFeedback = useCallback(
+ async (feedback: IAuthFeedback): Promise => {
+ await sendFeedback('POST', path, feedback);
+ await refetchFeedback();
+ },
+ [path, refetchFeedback]
+ );
+
+ const updateFeedback = useCallback(
+ async (feedback: IAuthFeedback): Promise => {
+ const pathWithId = `${path}/${feedback.feedbackId}`;
+ await sendFeedback('PUT', pathWithId, feedback);
+ await refetchFeedback();
+ },
+ [path, refetchFeedback]
+ );
+
+ return {
+ createFeedback,
+ updateFeedback,
+ };
+};
+
+const sendFeedback = async (
+ method: 'PUT' | 'POST',
+ path: string,
+ feedback: IAuthFeedback
+): Promise => {
+ await fetch(path, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(feedback),
+ });
+};
diff --git a/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts b/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts
index beed59a25c..3e7cb15bb2 100644
--- a/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts
+++ b/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts
@@ -32,10 +32,10 @@ export interface IAuthOptions {
}
export interface IAuthFeedback {
- neverShow: boolean;
feedbackId: string;
+ neverShow?: boolean;
given?: string;
- userId: number;
+ userId?: number;
}
export interface IAuthSplash {
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index ccacab7700..3162a15978 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -13,6 +13,7 @@ import ScrollToTop from 'component/scrollToTop';
import AccessProvider from './component/providers/AccessProvider/AccessProvider';
import { getBasePath } from 'utils/formatPath';
import UIProvider from './component/providers/UIProvider/UIProvider';
+import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider';
ReactDOM.render(
@@ -21,10 +22,12 @@ ReactDOM.render(
-
-
-
-
+
+
+
+
+
+