Repository: PostHog/posthog
Pull request: feat(surveys): Add support for duplicating survey
## Problem
Attempts to address - fixes https://github.com/PostHog/posthog/issues/18615

## Changes
Duplication are handled with getting existing survey, patching some of it, and then requesting for a new survey.
Alternatively, since this causes two waterfall calls, we can add a specific endpoint which handles this same logic.

Thinking about record duplication, since survey names are unique, appending `(copy)` or something similar to the survey name would make sense. Other cases, like if I'm interested in a duplicate of an archived survey, it should be created with `not` an archived state, so I can start using it immediately. Apart from that, I couldn't think of any other cases.

https://github.com/PostHog/posthog/assets/44131905/a1a1c401-f76e-4e0b-aa1f-b50e229db7d0


## Does this work well for both Cloud and self-hosted?
It doesn't have an impact.

## How did you test this code?
- Checked list of surveys - duplication results in new record. Success is a message, Failure is bubbled and handled by creation call.
    - For example, trying to duplicate the same survey twice will result in same name logic updation i.e `Survey (copy)` and `Survey (copy)`. Creation of a survey handles this (by failing)
- Checked inside a particular survey, when we duplicate - we show a relevant message & switch to that survey. History is updated.

Test cases - I wanted to wait a bit till I got some feedback on which approach would be better. I'll add tests accordingly to that area and remove the other one.

Problem statement:
Duplicate a survey
This would be quite useful to have, because I tested a survey by filling it out myself, and now I want to get rid of the answer so that it doesn’t skew the NPS. But I can’t delete events, so I thought I duplicate it. Nope :confused:

Hints from issue discussion:

Commit message: SURVEYS: BACKENDS: Add an endpoint for duplication

Patch to review:
diff --git a/frontend/src/scenes/surveys/SurveyView.tsx b/frontend/src/scenes/surveys/SurveyView.tsx
index 95c2ca6df47cf..68a3b18fcfc71 100644
--- a/frontend/src/scenes/surveys/SurveyView.tsx
+++ b/frontend/src/scenes/surveys/SurveyView.tsx
@@ -34,8 +34,16 @@ import {
 
 export function SurveyView({ id }: { id: string }): JSX.Element {
     const { survey, surveyLoading, selectedQuestion, targetingFlagFilters } = useValues(surveyLogic)
-    const { editingSurvey, updateSurvey, launchSurvey, stopSurvey, archiveSurvey, resumeSurvey, setSelectedQuestion } =
-        useActions(surveyLogic)
+    const {
+        editingSurvey,
+        updateSurvey,
+        launchSurvey,
+        stopSurvey,
+        archiveSurvey,
+        resumeSurvey,
+        setSelectedQuestion,
+        duplicateSurvey,
+    } = useActions(surveyLogic)
     const { deleteSurvey } = useActions(surveysLogic)
 
     const [tabKey, setTabKey] = useState(survey.start_date ? 'results' : 'overview')
@@ -66,6 +74,13 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
                                                 >
                                                     Edit
                                                 </LemonButton>
+                                                <LemonButton
+                                                    data-attr="duplicate-survey"
+                                                    fullWidth
+                                                    onClick={duplicateSurvey}
+                                                >
+                                                    Duplicate
+                                                </LemonButton>
                                                 <LemonDivider />
                                             </>
                                             {survey.end_date && !survey.archived && (
diff --git a/frontend/src/scenes/surveys/Surveys.tsx b/frontend/src/scenes/surveys/Surveys.tsx
index cc90337a44947..69034116225c1 100644
--- a/frontend/src/scenes/surveys/Surveys.tsx
+++ b/frontend/src/scenes/surveys/Surveys.tsx
@@ -58,7 +58,7 @@ export function Surveys(): JSX.Element {
         showSurveysDisabledBanner,
     } = useValues(surveysLogic)
 
-    const { deleteSurvey, updateSurvey, setSearchTerm, setSurveysFilters } = useActions(surveysLogic)
+    const { deleteSurvey, updateSurvey, setSearchTerm, setSurveysFilters, duplicateSurvey } = useActions(surveysLogic)
 
     const { user } = useValues(userLogic)
 
@@ -311,6 +311,14 @@ export function Surveys(): JSX.Element {
                                                                 Resume survey
                                                             </LemonButton>
                                                         )}
+                                                        <LemonButton
+                                                            fullWidth
+                                                            onClick={() => {
+                                                                duplicateSurvey(survey.id)
+                                                            }}
+                                                        >
+                                                            Duplicate Survey
+                                                        </LemonButton>
                                                         <LemonDivider />
                                                         {survey.end_date && survey.archived && (
                                                             <LemonButton
diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx
index 557f19289d976..820b4451ac260 100644
--- a/frontend/src/scenes/surveys/surveyLogic.tsx
+++ b/frontend/src/scenes/surveys/surveyLogic.tsx
@@ -26,7 +26,7 @@ import {
 
 import { defaultSurveyFieldValues, NEW_SURVEY, NewSurvey } from './constants'
 import type { surveyLogicType } from './surveyLogicType'
-import { surveysLogic } from './surveysLogic'
+import { duplicateExistingSurvey, surveysLogic } from './surveysLogic'
 import { sanitizeHTML } from './utils'
 
 export enum SurveyEditSection {
@@ -160,6 +160,11 @@ export const surveyLogic = kea<surveyLogicType>([
             updateSurvey: async (surveyPayload: Partial<Survey>) => {
                 return await api.surveys.update(props.id, sanitizeQuestions(surveyPayload))
             },
+            duplicateSurvey: async () => {
+                const { survey } = values
+                const payload = duplicateExistingSurvey(survey)
+                return await api.surveys.create(sanitizeQuestions(payload))
+            },
             launchSurvey: async () => {
                 const startDate = dayjs()
                 return await api.surveys.update(props.id, { start_date: startDate.toISOString() })
@@ -413,6 +418,11 @@ export const surveyLogic = kea<surveyLogicType>([
             actions.reportSurveyEdited(survey)
             actions.loadSurveys()
         },
+        duplicateSurveySuccess: ({ survey }) => {
+            lemonToast.success(<>Survey {survey.name} is a duplicate copy, and can be edited now</>)
+            actions.loadSurveys()
+            router.actions.replace(urls.survey(survey.id))
+        },
         launchSurveySuccess: ({ survey }) => {
             lemonToast.success(<>Survey {survey.name} launched</>)
             actions.loadSurveys()
diff --git a/frontend/src/scenes/surveys/surveysLogic.tsx b/frontend/src/scenes/surveys/surveysLogic.tsx
index 805510e0f34ca..aeab7fc4b9a77 100644
--- a/frontend/src/scenes/surveys/surveysLogic.tsx
+++ b/frontend/src/scenes/surveys/surveysLogic.tsx
@@ -11,8 +11,23 @@ import { userLogic } from 'scenes/userLogic'
 
 import { AvailableFeature, Breadcrumb, ProgressStatus, Survey, SurveyType } from '~/types'
 
+import { NEW_SURVEY, NewSurvey } from './constants'
 import type { surveysLogicType } from './surveysLogicType'
 
+export function duplicateExistingSurvey(survey: Partial<Survey>): NewSurvey {
+    const copiedNewSurvey = Object.keys(NEW_SURVEY).reduce((acc, key) => {
+        acc[key] = survey[key]
+        return acc
+    }, {}) as NewSurvey
+
+    return {
+        ...copiedNewSurvey,
+        id: NEW_SURVEY.id,
+        name: `${survey.name} (copy)`,
+        archived: false,
+    }
+}
+
 export function getSurveyStatus(survey: Survey): ProgressStatus {
     if (!survey.start_date) {
         return ProgressStatus.Draft
@@ -53,6 +68,11 @@ export const surveysLogic = kea<surveysLogicType>([
                 const updatedSurvey = await api.surveys.update(id, { ...updatePayload })
                 return values.surveys.map((survey) => (survey.id === id ? updatedSurvey : survey))
             },
+            duplicateSurvey: async (id) => {
+                const surveyToDuplicate = await api.surveys.get(id)
+                const newSurvey = await api.surveys.create(duplicateExistingSurvey(surveyToDuplicate))
+                return [...values.surveys, newSurvey]
+            },
         },
         surveysResponsesCount: {
             __default: {} as { [key: string]: number },
@@ -95,6 +115,10 @@ export const surveysLogic = kea<surveysLogicType>([
         loadSurveysSuccess: () => {
             actions.loadCurrentTeam()
         },
+        duplicateSurveySuccess: () => {
+            lemonToast.success('Survey duplicated')
+            actions.loadCurrentTeam()
+        },
     })),
     selectors({
         searchedSurveys: [
diff --git a/posthog/api/survey.py b/posthog/api/survey.py
index cb991a5f95abe..9f0418cf1e0cd 100644
--- a/posthog/api/survey.py
+++ b/posthog/api/survey.py
@@ -303,6 +303,22 @@ def responses_count(self, request: request.Request, **kwargs):
 
         return Response(counts)
 
+    # Unused -> Remove if not needed
+    @action(methods=["POST"], detail=True)
+    def duplicate_survey(self, request: request.Request, **kwargs):
+        survey = self.get_object()
+        serializer = SurveySerializerCreateUpdateOnly(survey, context={"team_id": self.team_id, "request": request})
+
+        data = serializer.data
+        data.pop("id", None)
+        data["name"] = f"{data['name']} (copy)"
+        data["archived"] = False
+
+        new_survey = serializer.create(validated_data=data)
+        new_data = SurveySerializerCreateUpdateOnly(new_survey).data
+
+        return Response(data=new_data)
+
 
 class SurveyAPISerializer(serializers.ModelSerializer):
     """


Code review comments:
diff_hunk: @@ -53,6 +68,11 @@ export const surveysLogic = kea<surveysLogicType>([
                 const updatedSurvey = await api.surveys.update(id, { ...updatePayload })
                 return values.surveys.map((survey) => (survey.id === id ? updatedSurvey : survey))
             },
+            duplicateSurvey: async (id) => {
+                const surveyToDuplicate = await api.surveys.get(id)
path: frontend/src/scenes/surveys/surveysLogic.tsx
text: @user1:
for now let's not allow duplication via the survey list, only via an existing open survey - so we get rid of this two request problem.

@author:
Reverted.

diff_hunk: @@ -413,6 +418,11 @@ export const surveyLogic = kea<surveyLogicType>([
             actions.reportSurveyEdited(survey)
             actions.loadSurveys()
         },
+        duplicateSurveySuccess: ({ survey }) => {
+            lemonToast.success(<>Survey {survey.name} is a duplicate copy, and can be edited now</>)
+            actions.loadSurveys()
+            router.actions.replace(urls.survey(survey.id))
path: frontend/src/scenes/surveys/surveyLogic.tsx
text: @user1:
let's not re-route automatically, use a similar flow as here: https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/cohorts/cohortEditLogic.ts#L299 (i.e. a button to view new survey)

@author:
Yup, that sounds better and in-line with other places, made the change.