From 65fda58127697fd2fc7ed54b388a2940ddb75532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Wed, 6 Aug 2025 21:23:56 -0300 Subject: [PATCH 01/18] Stub UI for importing unused sample We still have to pick the right survey, and probably show a preview. See #2389 --- assets/js/actions/respondentGroups.js | 11 +++++++++++ assets/js/api.js | 4 ++++ .../surveys/SurveyWizardRespondentsStep.jsx | 14 ++++++++++++++ lib/ask_web/router.ex | 1 + locales/template/translation.json | 1 + 5 files changed, 31 insertions(+) diff --git a/assets/js/actions/respondentGroups.js b/assets/js/actions/respondentGroups.js index 503e4820d..ff24623ce 100644 --- a/assets/js/actions/respondentGroups.js +++ b/assets/js/actions/respondentGroups.js @@ -12,6 +12,7 @@ export const CLEAR_INVALID_RESPONDENTS_FOR_GROUP = export const CLEAR_INVALIDS = "RESPONDENT_GROUP_CLEAR_INVALIDS" export const SELECT_CHANNELS = "RESPONDENT_GROUP_SELECT_CHANNELS" export const UPLOAD_RESPONDENT_GROUP = "RESPONDENT_GROUP_UPLOAD" +export const IMPORT_RESPONDENTS = "RESPONDENT_GROUP_IMPORT" export const UPLOAD_EXISTING_RESPONDENT_GROUP_ID = "RESPONDENT_GROUP_UPLOAD_EXISTING" export const DONE_UPLOAD_EXISTING_RESPONDENT_GROUP_ID = "RESPONDENT_GROUP_DONE_UPLOAD_EXISTING" @@ -62,6 +63,11 @@ export const uploadRespondentGroup = (projectId, surveyId, files) => (dispatch, handleRespondentGroupUpload(dispatch, api.uploadRespondentGroup(projectId, surveyId, files)) } +export const importUnusedSampleFromSurvey = (projectId, surveyId, sourceSurveyId) => (dispatch, getState) => { + dispatch(importingUnusedSample(sourceSurveyId)) + handleRespondentGroupUpload(dispatch, api.importUnusedSampleFromSurvey(projectId, surveyId, sourceSurveyId)) +} + export const addMoreRespondentsToGroup = (projectId, surveyId, groupId, file) => (dispatch, getState) => { dispatch(uploadingExistingRespondentGroup(groupId)) @@ -115,6 +121,11 @@ export const uploadingRespondentGroup = () => ({ type: UPLOAD_RESPONDENT_GROUP, }) +export const importingUnusedSample = (sourceSurveyId) => ({ + type: IMPORT_RESPONDENTS, + sourceSurveyId +}) + export const uploadingExistingRespondentGroup = (id) => ({ type: UPLOAD_EXISTING_RESPONDENT_GROUP_ID, id, diff --git a/assets/js/api.js b/assets/js/api.js index 01f519a81..81f648775 100644 --- a/assets/js/api.js +++ b/assets/js/api.js @@ -236,6 +236,10 @@ export const uploadRespondentGroup = (projectId, surveyId, files) => { ) } +export const importUnusedSampleFromSurvey = (projectId, surveyId, sourceSurveyId) => { + return apiPostJSON(`projects/${projectId}/surveys/${surveyId}/respondent_groups/import_unused`, { sourceSurveyId }) +} + export const addMoreRespondentsToGroup = (projectId, surveyId, groupId, file) => { return apiPostFile( `projects/${projectId}/surveys/${surveyId}/respondent_groups/${groupId}/add`, diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index 8ab851393..239e513e6 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -32,6 +32,11 @@ class SurveyWizardRespondentsStep extends Component { if (files.length > 0) actions.uploadRespondentGroup(survey.projectId, survey.id, files) } + importUnusedSample() { + const { survey, actions } = this.props + actions.importUnusedSampleFromSurvey(survey.projectId, survey.id, 1) // FIXME: actually pick the source survey ID + } + addMoreRespondents(groupId, file) { const { survey, actions } = this.props actions.addMoreRespondentsToGroup(survey.projectId, survey.id, groupId, file) @@ -341,6 +346,14 @@ class SurveyWizardRespondentsStep extends Component { const mode = survey.mode || [] const allModes = uniq(flatten(mode)) + let importUnusedSampleButton =
+
+ this.importUnusedSample()} className="btn-flat btn-flat-link"> + {t("Import uncontacted respondents from other survey")} + +
+
+ let respondentsDropzone = null if (!readOnly && !surveyStarted) { respondentsDropzone = ( @@ -386,6 +399,7 @@ class SurveyWizardRespondentsStep extends Component { showCancel /> {invalidRespondentsCard || respondentsDropzone} + {importUnusedSampleButton} ) } diff --git a/lib/ask_web/router.ex b/lib/ask_web/router.ex index 19cd72bdc..19581888d 100644 --- a/lib/ask_web/router.ex +++ b/lib/ask_web/router.ex @@ -109,6 +109,7 @@ defmodule AskWeb.Router do only: [:index, :create, :update, :delete] do post "/add", RespondentGroupController, :add, as: :add post "/replace", RespondentGroupController, :replace, as: :replace + post "/import_unused", RespondentGroupController, :import_unused, as: :import_unused end get "/respondents/stats", RespondentController, :stats, as: :respondents_stats diff --git a/locales/template/translation.json b/locales/template/translation.json index 405c06edc..b10425ebb 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -194,6 +194,7 @@ "Generated {{surveyName}} {{reportType}} file": "", "Ignored Values": "", "Import questionnaire": "", + "Import uncontacted respondents from other survey": "", "Incentive download was disabled because respondent ids were uploaded": "", "Incentives file": "", "Indicate percentages of all sampled cases that will be allocated to each experimental condition": "", From 8fdf255077400279a9b78718b0178bebd842dffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 7 Aug 2025 14:01:36 -0300 Subject: [PATCH 02/18] Stub importing unused sample from a fixed survey We still have to implement the source survey picker, and check it's finished. See #2389 --- assets/js/api.js | 2 +- lib/ask/runtime/respondent_group_action.ex | 5 +++ .../respondent_group_controller.ex | 45 ++++++++++++++++++- lib/ask_web/router.ex | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/assets/js/api.js b/assets/js/api.js index 81f648775..0f3345e30 100644 --- a/assets/js/api.js +++ b/assets/js/api.js @@ -237,7 +237,7 @@ export const uploadRespondentGroup = (projectId, surveyId, files) => { } export const importUnusedSampleFromSurvey = (projectId, surveyId, sourceSurveyId) => { - return apiPostJSON(`projects/${projectId}/surveys/${surveyId}/respondent_groups/import_unused`, { sourceSurveyId }) + return apiPostJSON(`projects/${projectId}/surveys/${surveyId}/respondent_groups/import_unused`, respondentGroupSchema, { sourceSurveyId }) } export const addMoreRespondentsToGroup = (projectId, surveyId, groupId, file) => { diff --git a/lib/ask/runtime/respondent_group_action.ex b/lib/ask/runtime/respondent_group_action.ex index 8703b7618..9927bde8a 100644 --- a/lib/ask/runtime/respondent_group_action.ex +++ b/lib/ask/runtime/respondent_group_action.ex @@ -271,6 +271,11 @@ defmodule Ask.Runtime.RespondentGroupAction do |> Repo.update!() end + def disable_incentives_if_disabled_in_source!(survey, %{incentives_enabled: false}), do: + Survey.changeset(survey, %{incentives_enabled: false}) + |> Repo.update!() + def disable_incentives_if_disabled_in_source!(survey, _), do: survey + defp validate_entries(entries) do if length(entries) == 0 do {:error, []} diff --git a/lib/ask_web/controllers/respondent_group_controller.ex b/lib/ask_web/controllers/respondent_group_controller.ex index 045ea79c1..655cf7ef4 100644 --- a/lib/ask_web/controllers/respondent_group_controller.ex +++ b/lib/ask_web/controllers/respondent_group_controller.ex @@ -3,7 +3,7 @@ defmodule AskWeb.RespondentGroupController do alias Ask.{Project, Survey, Respondent, RespondentGroup, Logger} alias Ask.Runtime.RespondentGroupAction - plug :find_and_check_survey_state when action in [:create, :update, :delete, :replace] + plug :find_and_check_survey_state when action in [:create, :import_unused, :update, :delete, :replace] def index(conn, %{"project_id" => project_id, "survey_id" => survey_id}) do project = @@ -47,6 +47,40 @@ defmodule AskWeb.RespondentGroupController do end end + def import_unused(conn, %{"source_survey_id" => source_survey_id}) do + project = conn.assigns.loaded_project + survey = conn.assigns.loaded_survey + + source_survey = + project + |> assoc(:surveys) + |> Repo.get!(source_survey_id) + + # FIXME: check the source survey is already finished + + entries = unused_respondents_from_survey(source_survey) + + if entries do + sample_name = "__imported_from_survey_#{source_survey.id}.csv" + case RespondentGroupAction.load_entries(entries, survey) do + {:ok, loaded_entries} -> + survey |> RespondentGroupAction.disable_incentives_if_disabled_in_source!(source_survey) + respondent_group = RespondentGroupAction.create(sample_name, loaded_entries, survey) + project |> Project.touch!() + + conn + |> put_status(:created) + |> render("show.json", respondent_group: respondent_group) + + {:error, invalid_entries} -> + render_invalid(conn, sample_name, invalid_entries) + end + else + Logger.warn("Error when creating respondent group for survey: #{inspect(survey)}") + render_unprocessable_entity(conn) + end + end + def update(conn, %{"id" => id, "respondent_group" => respondent_group_params}) do survey = conn.assigns.loaded_survey @@ -173,6 +207,15 @@ defmodule AskWeb.RespondentGroupController do end end + def unused_respondents_from_survey(survey) do + from(r in Respondent, + where: + r.survey_id == ^survey.id and r.disposition == :registered + ) + |> Repo.all() + |> Enum.map(fn r -> r.phone_number end) + end + defp csv_rows(csv_string) do csv_string |> String.splitter(["\r\n", "\r", "\n"]) diff --git a/lib/ask_web/router.ex b/lib/ask_web/router.ex index 19581888d..976818d6d 100644 --- a/lib/ask_web/router.ex +++ b/lib/ask_web/router.ex @@ -109,8 +109,8 @@ defmodule AskWeb.Router do only: [:index, :create, :update, :delete] do post "/add", RespondentGroupController, :add, as: :add post "/replace", RespondentGroupController, :replace, as: :replace - post "/import_unused", RespondentGroupController, :import_unused, as: :import_unused end + post "/respondent_groups/import_unused", RespondentGroupController, :import_unused, as: :import_unused get "/respondents/stats", RespondentController, :stats, as: :respondents_stats get "/simulation/initial_state/:mode", SurveySimulationController, :initial_state From abe5afa6e958bfa5ecac184fcebe0497d6c434be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 14 Aug 2025 20:19:34 -0300 Subject: [PATCH 03/18] Stub Survey picker modal for Import unused sample We still have to show the unused respondents count, and maybe fix the style a bit. There's also some error handling left before finishing the feature. See #2389 See #2390 --- .../components/surveys/ImportSampleModal.js | 76 +++++++++++++++++++ .../surveys/RespondentsDropzone.jsx | 4 +- assets/js/components/surveys/SurveyEdit.jsx | 8 ++ assets/js/components/surveys/SurveyForm.jsx | 6 ++ .../js/components/surveys/SurveySettings.jsx | 8 ++ .../surveys/SurveyWizardRespondentsStep.jsx | 30 +++++++- assets/js/components/ui/CardTable.jsx | 4 +- assets/js/i18nStyle.jsx | 3 + assets/js/reducers/respondentGroups.js | 14 ++++ locales/template/translation.json | 6 +- 10 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 assets/js/components/surveys/ImportSampleModal.js diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js new file mode 100644 index 000000000..a3c5195ae --- /dev/null +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -0,0 +1,76 @@ +import React, { Component, PropTypes } from "react" +import { CardTable, Modal } from "../ui" +import { translate } from "react-i18next" +import values from "lodash/values" + +class ImportSampleModal extends Component { + static propTypes = { + t: PropTypes.func, + projectSurveys: PropTypes.object, + onConfirm: PropTypes.func.isRequired, + modalId: PropTypes.string.isRequired, + style: PropTypes.object, + } + + constructor(props) { + super(props) + this.state = { + buckets: {}, + steps: {}, + } + } + + onSubmit(selectedSurveyId) { + let { onConfirm, modalId } = this.props + $(`#${modalId}`).modal("close") + onConfirm(selectedSurveyId) + } + + render() { + const { projectSurveys, modalId, style, t } = this.props + + let surveys = values(projectSurveys || {}).filter((survey) => survey.state == "terminated") + + return ( +
+ +
+
+
{t("Import unused respondents")}
+

{t("You can import the respondents that haven't been contacted in finished surveys of the project")}

+
+ + + + + + + + {t("Name")} + { /* {t("Unused respondents")} FIXME: should be this th instead of survey id */ } + {t("Survey ID")} + + + + {surveys.map((survey) => { + let name = survey.name ? `${survey.name} (#${survey.id})` : Untitled Survey #{survey.id} + let unusedSampleCount = survey.id // FIXME: should be the respondents count + return this.onSubmit(survey.id)}> + {name} + {unusedSampleCount} + })} + + +
+ +
+
+ ) + } +} + +export default translate()(ImportSampleModal) diff --git a/assets/js/components/surveys/RespondentsDropzone.jsx b/assets/js/components/surveys/RespondentsDropzone.jsx index 9d66f0b47..1b9e7bd5c 100644 --- a/assets/js/components/surveys/RespondentsDropzone.jsx +++ b/assets/js/components/surveys/RespondentsDropzone.jsx @@ -2,9 +2,10 @@ import React, { PropTypes } from "react" import Dropzone from "react-dropzone" import { Preloader } from "react-materialize" -export const RespondentsDropzone = ({ survey, uploading, onDrop, onDropRejected }) => { +export const RespondentsDropzone = ({ survey, uploading, importing, onDrop, onDropRejected }) => { let className = "drop-text csv" if (uploading) className += " uploading" + if (importing) className += " importing" let icon = null if (uploading) { @@ -53,6 +54,7 @@ export const dropzoneProps = () => { RespondentsDropzone.propTypes = { survey: PropTypes.object, uploading: PropTypes.bool, + importing: PropTypes.bool, onDrop: PropTypes.func.isRequired, onDropRejected: PropTypes.func.isRequired, } diff --git a/assets/js/components/surveys/SurveyEdit.jsx b/assets/js/components/surveys/SurveyEdit.jsx index 335bd37f6..558d8df31 100644 --- a/assets/js/components/surveys/SurveyEdit.jsx +++ b/assets/js/components/surveys/SurveyEdit.jsx @@ -15,6 +15,7 @@ class SurveyEdit extends Component { t: PropTypes.func, dispatch: PropTypes.func, projectId: PropTypes.any.isRequired, + projectSurveys: PropTypes.object, surveyId: PropTypes.any.isRequired, router: PropTypes.object.isRequired, survey: PropTypes.object.isRequired, @@ -23,6 +24,7 @@ class SurveyEdit extends Component { project: PropTypes.object, respondentGroups: PropTypes.object, respondentGroupsUploading: PropTypes.bool, + respondentGroupsImporting: PropTypes.bool, respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, @@ -55,11 +57,13 @@ class SurveyEdit extends Component { survey, projectId, project, + projectSurveys, questionnaires, dispatch, channels, respondentGroups, respondentGroupsUploading, + respondentGroupsImporting, respondentGroupsUploadingExisting, invalidRespondents, invalidGroup, @@ -90,10 +94,12 @@ class SurveyEdit extends Component { survey={survey} respondentGroups={respondentGroups} respondentGroupsUploading={respondentGroupsUploading} + respondentGroupsImporting={respondentGroupsImporting} respondentGroupsUploadingExisting={respondentGroupsUploadingExisting} invalidRespondents={invalidRespondents} invalidGroup={invalidGroup} projectId={projectId} + projectSurveys={projectSurveys} questionnaires={activeQuestionnaires} channels={channels} dispatch={dispatch} @@ -108,11 +114,13 @@ class SurveyEdit extends Component { const mapStateToProps = (state, ownProps) => ({ projectId: ownProps.params.projectId, project: state.project.data, + projectSurveys: state.surveys.items, surveyId: ownProps.params.surveyId, channels: state.channels.items, questionnaires: state.questionnaires.items || {}, respondentGroups: state.respondentGroups.items || {}, respondentGroupsUploading: state.respondentGroups.uploading, + respondentGroupsImporting: state.respondentGroups.importing, respondentGroupsUploadingExisting: state.respondentGroups.uploadingExisting, invalidRespondents: state.respondentGroups.invalidRespondents, invalidGroup: state.respondentGroups.invalidRespondentsForGroup, diff --git a/assets/js/components/surveys/SurveyForm.jsx b/assets/js/components/surveys/SurveyForm.jsx index ce6588815..4784e8603 100644 --- a/assets/js/components/surveys/SurveyForm.jsx +++ b/assets/js/components/surveys/SurveyForm.jsx @@ -24,6 +24,7 @@ class SurveyForm extends Component { t: PropTypes.func, dispatch: PropTypes.func, projectId: PropTypes.any.isRequired, + projectSurveys: PropTypes.object, survey: PropTypes.object.isRequired, surveyId: PropTypes.any.isRequired, router: PropTypes.object.isRequired, @@ -31,6 +32,7 @@ class SurveyForm extends Component { questionnaire: PropTypes.object, respondentGroups: PropTypes.object, respondentGroupsUploading: PropTypes.bool, + respondentGroupsImporting: PropTypes.bool, respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, @@ -89,10 +91,12 @@ class SurveyForm extends Component { const { survey, projectId, + projectSurveys, questionnaires, channels, respondentGroups, respondentGroupsUploading, + respondentGroupsImporting, respondentGroupsUploadingExisting, invalidRespondents, invalidGroup, @@ -308,9 +312,11 @@ class SurveyForm extends Component { ({ projectId: ownProps.params.projectId, project: state.project.data, + projectSurveys: state.surveys.items, surveyId: ownProps.params.surveyId, channels: state.channels.items, questionnaires: (state.survey.data || {}).questionnaires || state.questionnaires.items || {}, respondentGroups: state.respondentGroups.items || {}, respondentGroupsUploading: state.respondentGroups.uploading, + respondentGroupsImporting: state.respondentGroups.importing, respondentGroupsUploadingExisting: state.respondentGroups.uploadingExisting, invalidRespondents: state.respondentGroups.invalidRespondents, invalidGroup: state.respondentGroups.invalidRespondentsForGroup, diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index 239e513e6..0a07e626f 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -4,8 +4,10 @@ import { connect } from "react-redux" import { Preloader } from "react-materialize" import { ConfirmationModal, Card } from "../ui" import * as actions from "../../actions/respondentGroups" +import * as surveysActions from "../../actions/surveys" import uniq from "lodash/uniq" import flatten from "lodash/flatten" +import ImportSampleModal from "./ImportSampleModal" import { RespondentsList } from "./RespondentsList" import { RespondentsDropzone } from "./RespondentsDropzone" import { RespondentsContainer } from "./RespondentsContainer" @@ -16,8 +18,10 @@ class SurveyWizardRespondentsStep extends Component { static propTypes = { t: PropTypes.func, survey: PropTypes.object, + projectSurveys: PropTypes.object, respondentGroups: PropTypes.object.isRequired, respondentGroupsUploading: PropTypes.bool, + respondentGroupsImporting: PropTypes.bool, respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, @@ -32,9 +36,16 @@ class SurveyWizardRespondentsStep extends Component { if (files.length > 0) actions.uploadRespondentGroup(survey.projectId, survey.id, files) } - importUnusedSample() { + showImportUnusedSampleModal(e) { + e.preventDefault() + let { projectId, surveysActions} = this.props + surveysActions.fetchSurveys(projectId) + $('#importUnusedSampleModal').modal("open") + } + + importUnusedSample(sourceSurveyId) { const { survey, actions } = this.props - actions.importUnusedSampleFromSurvey(survey.projectId, survey.id, 1) // FIXME: actually pick the source survey ID + actions.importUnusedSampleFromSurvey(survey.projectId, survey.id, sourceSurveyId) } addMoreRespondents(groupId, file) { @@ -329,9 +340,11 @@ class SurveyWizardRespondentsStep extends Component { render() { let { survey, + projectSurveys, channels, respondentGroups, respondentGroupsUploading, + respondentGroupsImporting, respondentGroupsUploadingExisting, invalidRespondents, readOnly, @@ -348,18 +361,25 @@ class SurveyWizardRespondentsStep extends Component { let importUnusedSampleButton =
+ let importUnusedSampleModal = + let respondentsDropzone = null if (!readOnly && !surveyStarted) { respondentsDropzone = ( this.handleSubmit(file)} onDropRejected={() => $("#invalidTypeFile").modal("open")} /> @@ -400,6 +420,7 @@ class SurveyWizardRespondentsStep extends Component { /> {invalidRespondentsCard || respondentsDropzone} {importUnusedSampleButton} + {importUnusedSampleModal} ) } @@ -407,6 +428,7 @@ class SurveyWizardRespondentsStep extends Component { const mapDispatchToProps = (dispatch) => ({ actions: bindActionCreators(actions, dispatch), + surveysActions: bindActionCreators(surveysActions, dispatch) }) export default translate()(connect(null, mapDispatchToProps)(SurveyWizardRespondentsStep)) diff --git a/assets/js/components/ui/CardTable.jsx b/assets/js/components/ui/CardTable.jsx index 4b594f9d1..ef931ad01 100644 --- a/assets/js/components/ui/CardTable.jsx +++ b/assets/js/components/ui/CardTable.jsx @@ -18,7 +18,7 @@ export const CardTable = ({ tableScroll: tableScroll, })} > -
{title}
+ { title ?
{title}
: null }
{children} @@ -31,7 +31,7 @@ export const CardTable = ({ ) CardTable.propTypes = { - title: PropTypes.node.isRequired, + title: PropTypes.node, highlight: PropTypes.bool, tableScroll: PropTypes.bool, children: PropTypes.node, diff --git a/assets/js/i18nStyle.jsx b/assets/js/i18nStyle.jsx index 500b09fbd..e7a20b556 100644 --- a/assets/js/i18nStyle.jsx +++ b/assets/js/i18nStyle.jsx @@ -17,6 +17,9 @@ class I18nStyle extends Component { .dropfile .drop-text.csv.uploading:before { content: '${t("Uploading...")}'; } +.dropfile .drop-text.csv.uploading.importing:before { + content: '${t("Importing...")}'; +} .dropfile .drop-text.audio:before { content: '${t("Drop your MP3, WAV, M4A, ACC or MP4 file here, or click to browse")}'; } diff --git a/assets/js/reducers/respondentGroups.js b/assets/js/reducers/respondentGroups.js index f1aa6829a..5f998ba7c 100644 --- a/assets/js/reducers/respondentGroups.js +++ b/assets/js/reducers/respondentGroups.js @@ -3,6 +3,7 @@ import * as actions from "../actions/respondentGroups" const initialState = { fetching: false, uploading: false, + importing: false, uploadingExisting: {}, items: null, surveyId: null, @@ -16,6 +17,8 @@ export default (state = initialState, action) => { return fetchRespondentGroups(state, action) case actions.UPLOAD_RESPONDENT_GROUP: return uploadRespondentGroup(state, action) + case actions.IMPORT_RESPONDENTS: + return importRespondentGroup(state, action) case actions.UPLOAD_EXISTING_RESPONDENT_GROUP_ID: return uploadExistingRespondentGroup(state, action) case actions.DONE_UPLOAD_EXISTING_RESPONDENT_GROUP_ID: @@ -59,6 +62,14 @@ const uploadRespondentGroup = (state, action) => { } } +const importRespondentGroup = (state, action) => { + return { + ...state, + uploading: true, + importing: true, + } +} + const uploadExistingRespondentGroup = (state, action) => { return { ...state, @@ -89,6 +100,7 @@ const receiveRespondentGroups = (state, action) => { ...state, fetching: false, uploading: false, + importing: false, items: respondentGroups, invalidRespondents: null, } @@ -100,6 +112,7 @@ const receiveRespondentGroup = (state, action) => { ...state, fetching: false, uploading: false, + importing: false, items: { ...state.items, [group.id]: group, @@ -128,6 +141,7 @@ const clearInvalids = (state, action) => { ...state, invalidRespondents: null, uploading: false, + importing: false, } } diff --git a/locales/template/translation.json b/locales/template/translation.json index b10425ebb..cfbc181a5 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -194,7 +194,8 @@ "Generated {{surveyName}} {{reportType}} file": "", "Ignored Values": "", "Import questionnaire": "", - "Import uncontacted respondents from other survey": "", + "Import unused respondents": "", + "Importing...": "", "Incentive download was disabled because respondent ids were uploaded": "", "Incentives file": "", "Indicate percentages of all sampled cases that will be allocated to each experimental condition": "", @@ -469,6 +470,7 @@ "Sun": "", "Surveda will sync available channels from these providers after user authorization": "", "Survey": "", + "Survey ID": "", "Survey results": "", "Surveys": "", "Target": "", @@ -522,6 +524,7 @@ "Untitled questionnaire": "", "Untitled questionnaire section": "", "Untitled section": "", + "Unused respondents": "", "Update": "", "Updated invitation for {{collaboratorEmail}} from {{oldRole}} to {{newRole}}": "", "Updated membership for {{collaboratorName}} from {{oldRole}} to {{newRole}}": "", @@ -557,6 +560,7 @@ "Write your message here": "", "Yes": "", "You are about to delete all the waves of this panel survey.": "", + "You can import the respondents that haven't been contacted in finished surveys of the project": "", "You have no active projects": "", "You have no active questionnaires on this project": "", "You have no channels on this project": "", From 5ac35e2c900b4f0bc3d1918ecd0902c7bafccc82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Fri, 15 Aug 2025 15:40:32 -0300 Subject: [PATCH 04/18] Import sample only from terminated surveys We don't want to import from surveys that are still running. See #2389 --- .../respondent_group_controller.ex | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/lib/ask_web/controllers/respondent_group_controller.ex b/lib/ask_web/controllers/respondent_group_controller.ex index 655cf7ef4..bfa369de3 100644 --- a/lib/ask_web/controllers/respondent_group_controller.ex +++ b/lib/ask_web/controllers/respondent_group_controller.ex @@ -56,28 +56,31 @@ defmodule AskWeb.RespondentGroupController do |> assoc(:surveys) |> Repo.get!(source_survey_id) - # FIXME: check the source survey is already finished - - entries = unused_respondents_from_survey(source_survey) - - if entries do - sample_name = "__imported_from_survey_#{source_survey.id}.csv" - case RespondentGroupAction.load_entries(entries, survey) do - {:ok, loaded_entries} -> - survey |> RespondentGroupAction.disable_incentives_if_disabled_in_source!(source_survey) - respondent_group = RespondentGroupAction.create(sample_name, loaded_entries, survey) - project |> Project.touch!() - - conn - |> put_status(:created) - |> render("show.json", respondent_group: respondent_group) - - {:error, invalid_entries} -> - render_invalid(conn, sample_name, invalid_entries) - end - else - Logger.warn("Error when creating respondent group for survey: #{inspect(survey)}") + if !Survey.terminated?(source_survey) do + Logger.warn("Can't import sample from survey ##{source_survey.id} - state is #{source_survey.state} instead of terminated") render_unprocessable_entity(conn) + else + entries = unused_respondents_from_survey(source_survey) + + if entries do + sample_name = "__imported_from_survey_#{source_survey.id}.csv" + case RespondentGroupAction.load_entries(entries, survey) do + {:ok, loaded_entries} -> + survey |> RespondentGroupAction.disable_incentives_if_disabled_in_source!(source_survey) + respondent_group = RespondentGroupAction.create(sample_name, loaded_entries, survey) + project |> Project.touch!() + + conn + |> put_status(:created) + |> render("show.json", respondent_group: respondent_group) + + {:error, invalid_entries} -> + render_invalid(conn, sample_name, invalid_entries) + end + else + Logger.warn("Error when creating respondent group for survey: #{inspect(survey)}") + render_unprocessable_entity(conn) + end end end From d445ea03c6828451b10ba8d1aa9d7997bb07d491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Mon, 25 Aug 2025 19:19:14 -0300 Subject: [PATCH 05/18] Add unused sample surveys endpoint See #2389 --- lib/ask_web/controllers/survey_controller.ex | 27 ++++++++++++++++++++ lib/ask_web/router.ex | 2 ++ lib/ask_web/views/survey_view.ex | 4 +++ 3 files changed, 33 insertions(+) diff --git a/lib/ask_web/controllers/survey_controller.ex b/lib/ask_web/controllers/survey_controller.ex index 1f812ad5b..e0989e42a 100644 --- a/lib/ask_web/controllers/survey_controller.ex +++ b/lib/ask_web/controllers/survey_controller.ex @@ -5,6 +5,7 @@ defmodule AskWeb.SurveyController do Project, Folder, Survey, + Respondent, Logger, ActivityLog, QuotaBucket, @@ -58,6 +59,32 @@ defmodule AskWeb.SurveyController do render(conn, "index.json", surveys: surveys) end + # select s.id, count(1) + # from surveys s left join respondents r on r.survey_id = s.id + # where s.state = 'terminated' and r.disposition = 'registered' and s.project_id = 38 + # group by s.id; + def list_unused(conn, %{"project_id" => project_id}) do + project = load_project(conn, project_id) + + surveys = + Repo.all( + from s in Survey, + left_join: r in Respondent, + on: r.survey_id == s.id, + where: s.project_id == ^project.id and s.state == :terminated, + select: %{survey_id: s.id, name: s.name, ended_at: s.ended_at, respondents: sum(fragment("if(?, ?, ?)", r.disposition == :registered, 1, 0))}, + group_by: [s.id] + ) |> Enum.map(fn s -> %{ + survey_id: s.survey_id, + name: s.name, + ended_at: s.ended_at, + respondents: s.respondents |> Decimal.to_integer + } end) + |> Enum.sort_by(fn s -> - s.respondents end) + + render(conn, "unused_sample.json", surveys: surveys) + end + def create(conn, params = %{"project_id" => project_id}) do project = load_project_for_change(conn, project_id) diff --git a/lib/ask_web/router.ex b/lib/ask_web/router.ex index 976818d6d..835b8a844 100644 --- a/lib/ask_web/router.ex +++ b/lib/ask_web/router.ex @@ -129,6 +129,8 @@ defmodule AskWeb.Router do end end + get "/unused_sample", SurveyController, :list_unused + post "/surveys/simulate_questionanire", SurveySimulationController, :simulate resources "/questionnaires", QuestionnaireController, except: [:new, :edit] do diff --git a/lib/ask_web/views/survey_view.ex b/lib/ask_web/views/survey_view.ex index d8ca12973..c9734110d 100644 --- a/lib/ask_web/views/survey_view.ex +++ b/lib/ask_web/views/survey_view.ex @@ -9,6 +9,10 @@ defmodule AskWeb.SurveyView do %{data: render_many(surveys, AskWeb.SurveyView, "survey.json")} end + def render("unused_sample.json", %{surveys: surveys}) do + %{data: surveys} + end + def render("show.json", %{survey: survey}) do %{data: render_one(survey, AskWeb.SurveyView, "survey_detail.json")} end From 14a5fd1afeba164cd6391f7aa4bff2a26390e928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Mon, 25 Aug 2025 19:20:26 -0300 Subject: [PATCH 06/18] Add unusedSample to Redux model We now have to use it from the UI. See #2389 --- assets/js/actions/surveys.js | 18 ++++++++++++++++++ assets/js/api.js | 4 ++++ assets/js/reducers/index.js | 2 ++ assets/js/reducers/unusedSample.js | 23 +++++++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 assets/js/reducers/unusedSample.js diff --git a/assets/js/actions/surveys.js b/assets/js/actions/surveys.js index 29459caaf..8423a5bc9 100644 --- a/assets/js/actions/surveys.js +++ b/assets/js/actions/surveys.js @@ -2,6 +2,8 @@ import * as api from "../api" export const RECEIVE = "RECEIVE_SURVEYS" +export const RECEIVE_UNUSED_SAMPLE_SURVEYS = "RECEIVE_UNUSED_SAMPLE_SURVEYS" +export const FETCHING_UNUSED_SAMPLE_SURVEYS = "FETCHING_UNUSED_SAMPLE_SURVEYS" export const FETCH = "FETCH_SURVEYS" export const NEXT_PAGE = "SURVEYS_NEXT_PAGE" export const PREVIOUS_PAGE = "SURVEYS_PREVIOUS_PAGE" @@ -31,6 +33,22 @@ export const fetchSurveys = .then(() => getState().surveys.items) } +export const fetchUnusedSample = (projectId: number) => (dispatch: Function, getState: () => Store): Promise => { + dispatch(fetchingUnusedSampleSurveys()) + return api.fetchUnusedSampleSurveys(projectId).then((surveys) => { + dispatch(receiveUnusedSampleSurveys(surveys)) + }) +} + +export const fetchingUnusedSampleSurveys = () => ({ + type: FETCHING_UNUSED_SAMPLE_SURVEYS +}) + +export const receiveUnusedSampleSurveys = (surveys: Array) => ({ + type: RECEIVE_UNUSED_SAMPLE_SURVEYS, + surveys +}) + export const startFetchingSurveys = (projectId: number) => ({ type: FETCH, projectId, diff --git a/assets/js/api.js b/assets/js/api.js index 0f3345e30..6c4c94cb5 100644 --- a/assets/js/api.js +++ b/assets/js/api.js @@ -236,6 +236,10 @@ export const uploadRespondentGroup = (projectId, surveyId, files) => { ) } +export const fetchUnusedSampleSurveys = (projectId) => { + return apiFetchJSON(`projects/${projectId}/unused_sample`, null) +} + export const importUnusedSampleFromSurvey = (projectId, surveyId, sourceSurveyId) => { return apiPostJSON(`projects/${projectId}/surveys/${surveyId}/respondent_groups/import_unused`, respondentGroupSchema, { sourceSurveyId }) } diff --git a/assets/js/reducers/index.js b/assets/js/reducers/index.js index e213d2be8..d90921c52 100644 --- a/assets/js/reducers/index.js +++ b/assets/js/reducers/index.js @@ -30,6 +30,7 @@ import surveyStats from "./surveyStats" import surveyRetriesHistograms from "./surveyRetriesHistograms" import panelSurveys from "./panelSurveys" import panelSurvey from "./panelSurvey" +import unusedSample from "./unusedSample" export default combineReducers({ activities, @@ -63,4 +64,5 @@ export default combineReducers({ surveyRetriesHistograms, panelSurveys, panelSurvey, + unusedSample, }) diff --git a/assets/js/reducers/unusedSample.js b/assets/js/reducers/unusedSample.js new file mode 100644 index 000000000..5834fd2a4 --- /dev/null +++ b/assets/js/reducers/unusedSample.js @@ -0,0 +1,23 @@ +// @flow +import * as actions from "../actions/surveys" + +const initialState = null + +export default (state = initialState, action) => { + switch (action.type) { + case actions.FETCHING_UNUSED_SAMPLE_SURVEYS: + return initialState + case actions.RECEIVE_UNUSED_SAMPLE_SURVEYS: + return receiveUnusedSampleSurveys(state, action) + default: + return state + } +} + +const receiveUnusedSampleSurveys = (state: Array, action: any) => ( + { + ...state, + surveys: action.surveys, + } +) + From 0b10b00787a2137c9edb23b9161431c99d9a2cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Mon, 25 Aug 2025 20:27:08 -0300 Subject: [PATCH 07/18] Show unused sample surveys in Import modal See #2389 --- .../components/surveys/ImportSampleModal.js | 40 ++++++++++++------- assets/js/components/surveys/SurveyEdit.jsx | 8 ++-- assets/js/components/surveys/SurveyForm.jsx | 6 +-- .../js/components/surveys/SurveySettings.jsx | 8 ++-- .../surveys/SurveyWizardRespondentsStep.jsx | 8 ++-- assets/js/reducers/unusedSample.js | 5 +-- locales/template/translation.json | 2 +- 7 files changed, 43 insertions(+), 34 deletions(-) diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js index a3c5195ae..bc16b24e6 100644 --- a/assets/js/components/surveys/ImportSampleModal.js +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -1,12 +1,13 @@ import React, { Component, PropTypes } from "react" import { CardTable, Modal } from "../ui" import { translate } from "react-i18next" +import { FormattedDate } from "react-intl" import values from "lodash/values" class ImportSampleModal extends Component { static propTypes = { t: PropTypes.func, - projectSurveys: PropTypes.object, + unusedSample: PropTypes.array, onConfirm: PropTypes.func.isRequired, modalId: PropTypes.string.isRequired, style: PropTypes.object, @@ -27,9 +28,28 @@ class ImportSampleModal extends Component { } render() { - const { projectSurveys, modalId, style, t } = this.props + const { unusedSample, modalId, style, t } = this.props - let surveys = values(projectSurveys || {}).filter((survey) => survey.state == "terminated") + let surveys = values(unusedSample || {}) + + let loadingDiv =
Loading...
// FIXME: to be improved + + let surveysTable =
+ {surveys.map((survey) => { + let name = survey.name ? `${survey.name} (#${survey.survey_id})` : Untitled Survey #{survey.survey_id} + return this.onSubmit(survey.survey_id)}> + + + + })} + return (
@@ -47,19 +67,11 @@ class ImportSampleModal extends Component {
- { /* FIXME: should be this th instead of survey id */ } - + + - - {surveys.map((survey) => { - let name = survey.name ? `${survey.name} (#${survey.id})` : Untitled Survey #{survey.id} - let unusedSampleCount = survey.id // FIXME: should be the respondents count - return this.onSubmit(survey.id)}> - - - })} - + { unusedSample ? surveysTable : loadingDiv }
diff --git a/assets/js/components/surveys/SurveyEdit.jsx b/assets/js/components/surveys/SurveyEdit.jsx index 558d8df31..f0004aa77 100644 --- a/assets/js/components/surveys/SurveyEdit.jsx +++ b/assets/js/components/surveys/SurveyEdit.jsx @@ -15,7 +15,7 @@ class SurveyEdit extends Component { t: PropTypes.func, dispatch: PropTypes.func, projectId: PropTypes.any.isRequired, - projectSurveys: PropTypes.object, + unusedSample: PropTypes.array, surveyId: PropTypes.any.isRequired, router: PropTypes.object.isRequired, survey: PropTypes.object.isRequired, @@ -57,7 +57,7 @@ class SurveyEdit extends Component { survey, projectId, project, - projectSurveys, + unusedSample, questionnaires, dispatch, channels, @@ -99,7 +99,7 @@ class SurveyEdit extends Component { invalidRespondents={invalidRespondents} invalidGroup={invalidGroup} projectId={projectId} - projectSurveys={projectSurveys} + unusedSample={unusedSample} questionnaires={activeQuestionnaires} channels={channels} dispatch={dispatch} @@ -114,7 +114,7 @@ class SurveyEdit extends Component { const mapStateToProps = (state, ownProps) => ({ projectId: ownProps.params.projectId, project: state.project.data, - projectSurveys: state.surveys.items, + unusedSample: state.unusedSample, surveyId: ownProps.params.surveyId, channels: state.channels.items, questionnaires: state.questionnaires.items || {}, diff --git a/assets/js/components/surveys/SurveyForm.jsx b/assets/js/components/surveys/SurveyForm.jsx index 4784e8603..b7b36f780 100644 --- a/assets/js/components/surveys/SurveyForm.jsx +++ b/assets/js/components/surveys/SurveyForm.jsx @@ -24,7 +24,7 @@ class SurveyForm extends Component { t: PropTypes.func, dispatch: PropTypes.func, projectId: PropTypes.any.isRequired, - projectSurveys: PropTypes.object, + unusedSample: PropTypes.array, survey: PropTypes.object.isRequired, surveyId: PropTypes.any.isRequired, router: PropTypes.object.isRequired, @@ -91,7 +91,7 @@ class SurveyForm extends Component { const { survey, projectId, - projectSurveys, + unusedSample, questionnaires, channels, respondentGroups, @@ -312,7 +312,7 @@ class SurveyForm extends Component { ({ projectId: ownProps.params.projectId, project: state.project.data, - projectSurveys: state.surveys.items, + unusedSample: state.unusedSample, surveyId: ownProps.params.surveyId, channels: state.channels.items, questionnaires: (state.survey.data || {}).questionnaires || state.questionnaires.items || {}, diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index 0a07e626f..ba84d095b 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -18,7 +18,7 @@ class SurveyWizardRespondentsStep extends Component { static propTypes = { t: PropTypes.func, survey: PropTypes.object, - projectSurveys: PropTypes.object, + unusedSample: PropTypes.array, respondentGroups: PropTypes.object.isRequired, respondentGroupsUploading: PropTypes.bool, respondentGroupsImporting: PropTypes.bool, @@ -39,7 +39,7 @@ class SurveyWizardRespondentsStep extends Component { showImportUnusedSampleModal(e) { e.preventDefault() let { projectId, surveysActions} = this.props - surveysActions.fetchSurveys(projectId) + surveysActions.fetchUnusedSample(projectId) $('#importUnusedSampleModal').modal("open") } @@ -340,7 +340,7 @@ class SurveyWizardRespondentsStep extends Component { render() { let { survey, - projectSurveys, + unusedSample, channels, respondentGroups, respondentGroupsUploading, @@ -368,7 +368,7 @@ class SurveyWizardRespondentsStep extends Component {
let importUnusedSampleModal = diff --git a/assets/js/reducers/unusedSample.js b/assets/js/reducers/unusedSample.js index 5834fd2a4..e6e661038 100644 --- a/assets/js/reducers/unusedSample.js +++ b/assets/js/reducers/unusedSample.js @@ -15,9 +15,6 @@ export default (state = initialState, action) => { } const receiveUnusedSampleSurveys = (state: Array, action: any) => ( - { - ...state, - surveys: action.surveys, - } + action.surveys ) diff --git a/locales/template/translation.json b/locales/template/translation.json index cfbc181a5..3326d7f83 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -164,6 +164,7 @@ "Enabled {{surveyName}} {{reportType}} link": "", "End date": "", "End section": "", + "Ended at": "", "Enter a delay like 10m, 3h or 1d to express a time unit (default 1h, use values greater than 10m)": "", "Enter collaborator's email": "", "Enter comma-separated values to create ranges like 5,10,20": "", @@ -470,7 +471,6 @@ "Sun": "", "Surveda will sync available channels from these providers after user authorization": "", "Survey": "", - "Survey ID": "", "Survey results": "", "Surveys": "", "Target": "", From f8332142b436a11a0804f85205181b7c7da8bd8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Wed, 27 Aug 2025 19:03:40 -0300 Subject: [PATCH 08/18] Add loading state for Import Sample modal See #2389 --- assets/css/_forms.scss | 13 ++++ .../components/surveys/ImportSampleModal.js | 67 ++++++++++--------- assets/js/i18nStyle.jsx | 3 + locales/template/translation.json | 1 + 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/assets/css/_forms.scss b/assets/css/_forms.scss index ccb4c643d..ad311cae4 100644 --- a/assets/css/_forms.scss +++ b/assets/css/_forms.scss @@ -121,6 +121,19 @@ label { } } +.import-sample-loading { + width: 100%; + height: 16rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; + + &:after { + @extend .blue-text, .lighten-1; + } +} + .audio-section { position: relative; padding-left: 10px; diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js index bc16b24e6..5bec95efa 100644 --- a/assets/js/components/surveys/ImportSampleModal.js +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react" import { CardTable, Modal } from "../ui" import { translate } from "react-i18next" import { FormattedDate } from "react-intl" +import { Preloader } from "react-materialize" import values from "lodash/values" class ImportSampleModal extends Component { @@ -32,24 +33,41 @@ class ImportSampleModal extends Component { let surveys = values(unusedSample || {}) - let loadingDiv =
Loading...
// FIXME: to be improved + let loadingDiv =
+
+ +
+
- let surveysTable =
- {surveys.map((survey) => { - let name = survey.name ? `${survey.name} (#${survey.survey_id})` : Untitled Survey #{survey.survey_id} - return this.onSubmit(survey.survey_id)}> - - - - })} - + let surveysTable = + + + + + + + + + + + + + {surveys.map((survey) => { + let name = survey.name ? `${survey.name} (#${survey.survey_id})` : Untitled Survey #{survey.survey_id} + return this.onSubmit(survey.survey_id)}> + + + + })} + + return (
@@ -59,21 +77,8 @@ class ImportSampleModal extends Component {
{t("Import unused respondents")}

{t("You can import the respondents that haven't been contacted in finished surveys of the project")}

- - - - - - - - - - - - - { unusedSample ? surveysTable : loadingDiv } - + { unusedSample ? surveysTable : loadingDiv }
{t("Cancel")} diff --git a/assets/js/i18nStyle.jsx b/assets/js/i18nStyle.jsx index e7a20b556..877fcb1a9 100644 --- a/assets/js/i18nStyle.jsx +++ b/assets/js/i18nStyle.jsx @@ -29,6 +29,9 @@ class I18nStyle extends Component { .dropfile.rejectedfile .drop-text:before { content: '${t("Invalid file type")}'; } +.import-sample-loading:after { + content: '${t("Listing unused respondents...")}'; +} .audio-section .drop-uploading:before { content: '${t("Uploading...")}'; } diff --git a/locales/template/translation.json b/locales/template/translation.json index 3326d7f83..15a38fd8e 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -237,6 +237,7 @@ "Leave project": "", "Limit the channel capacity": "", "List the texts you want to store for each possible choice and define valid values by commas.": "", + "Listing unused respondents...": "", "Loading activities...": "", "Loading channels...": "", "Loading panel survey...": "", From 740ae1d3ee0d54507995fd70cc57091b45963f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Wed, 27 Aug 2025 20:40:15 -0300 Subject: [PATCH 09/18] Make import action explicit in the UI See #2389 --- assets/js/components/surveys/ImportSampleModal.js | 15 ++++++++++++--- locales/template/translation.json | 1 + 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js index 5bec95efa..8cc06dec5 100644 --- a/assets/js/components/surveys/ImportSampleModal.js +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -22,7 +22,8 @@ class ImportSampleModal extends Component { } } - onSubmit(selectedSurveyId) { + onSubmit(event, selectedSurveyId) { + event.preventDefault() let { onConfirm, modalId } = this.props $(`#${modalId}`).modal("close") onConfirm(selectedSurveyId) @@ -41,20 +42,23 @@ class ImportSampleModal extends Component { let surveysTable =
- + + + + {surveys.map((survey) => { let name = survey.name ? `${survey.name} (#${survey.survey_id})` : Untitled Survey #{survey.survey_id} - return this.onSubmit(survey.survey_id)}> + return + })} diff --git a/locales/template/translation.json b/locales/template/translation.json index 15a38fd8e..0017b7b5d 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -194,6 +194,7 @@ "From": "", "Generated {{surveyName}} {{reportType}} file": "", "Ignored Values": "", + "Import": "", "Import questionnaire": "", "Import unused respondents": "", "Importing...": "", From 6aea5e8e2ed0144b8c4c1b82c6ab50141472a474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 20:12:20 -0300 Subject: [PATCH 10/18] Handle errors when importing unused sample See #2389 --- assets/js/actions/respondentGroups.js | 20 +++++++- assets/js/components/surveys/SurveyEdit.jsx | 4 ++ assets/js/components/surveys/SurveyForm.jsx | 3 ++ .../js/components/surveys/SurveySettings.jsx | 4 ++ .../surveys/SurveyWizardRespondentsStep.jsx | 49 ++++++++++++++++++- assets/js/reducers/respondentGroups.js | 23 +++++++++ .../respondent_group_controller.ex | 27 +++++++--- lib/ask_web/views/respondent_group_view.ex | 8 +++ locales/template/translation.json | 3 ++ 9 files changed, 130 insertions(+), 11 deletions(-) diff --git a/assets/js/actions/respondentGroups.js b/assets/js/actions/respondentGroups.js index ff24623ce..a8cb0ebae 100644 --- a/assets/js/actions/respondentGroups.js +++ b/assets/js/actions/respondentGroups.js @@ -13,6 +13,8 @@ export const CLEAR_INVALIDS = "RESPONDENT_GROUP_CLEAR_INVALIDS" export const SELECT_CHANNELS = "RESPONDENT_GROUP_SELECT_CHANNELS" export const UPLOAD_RESPONDENT_GROUP = "RESPONDENT_GROUP_UPLOAD" export const IMPORT_RESPONDENTS = "RESPONDENT_GROUP_IMPORT" +export const INVALID_IMPORT = "RESPONDENT_GROUP_INVALID_IMPORT" +export const CLEAR_INVALID_IMPORT = "RESPONDENT_GROUP_CLEAR_INVALID_IMPORT" export const UPLOAD_EXISTING_RESPONDENT_GROUP_ID = "RESPONDENT_GROUP_UPLOAD_EXISTING" export const DONE_UPLOAD_EXISTING_RESPONDENT_GROUP_ID = "RESPONDENT_GROUP_DONE_UPLOAD_EXISTING" @@ -39,6 +41,15 @@ export const receiveRespondentGroup = (respondentGroup) => ({ respondentGroup, }) +export const importInvalids = (importError) => ({ + type: INVALID_IMPORT, + importError +}) + +export const clearInvalidImport = () => ({ + type: CLEAR_INVALID_IMPORT, +}) + export const receiveInvalids = (invalidRespondents) => ({ type: INVALID_RESPONDENTS, invalidRespondents: invalidRespondents, @@ -65,7 +76,14 @@ export const uploadRespondentGroup = (projectId, surveyId, files) => (dispatch, export const importUnusedSampleFromSurvey = (projectId, surveyId, sourceSurveyId) => (dispatch, getState) => { dispatch(importingUnusedSample(sourceSurveyId)) - handleRespondentGroupUpload(dispatch, api.importUnusedSampleFromSurvey(projectId, surveyId, sourceSurveyId)) + api.importUnusedSampleFromSurvey(projectId, surveyId, sourceSurveyId).then((response) => { + const group = response.entities.respondentGroups[response.result] + dispatch(receiveRespondentGroup(group)) + }, (e) => { + e.json().then((error) => { + dispatch(importInvalids(error)) + }) + }) } export const addMoreRespondentsToGroup = diff --git a/assets/js/components/surveys/SurveyEdit.jsx b/assets/js/components/surveys/SurveyEdit.jsx index f0004aa77..d9c4c14fc 100644 --- a/assets/js/components/surveys/SurveyEdit.jsx +++ b/assets/js/components/surveys/SurveyEdit.jsx @@ -28,6 +28,7 @@ class SurveyEdit extends Component { respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, + invalidImport: PropTypes.object, } componentWillMount() { @@ -67,6 +68,7 @@ class SurveyEdit extends Component { respondentGroupsUploadingExisting, invalidRespondents, invalidGroup, + invalidImport, t, } = this.props const activeQuestionnaires = Object.keys(questionnaires) @@ -98,6 +100,7 @@ class SurveyEdit extends Component { respondentGroupsUploadingExisting={respondentGroupsUploadingExisting} invalidRespondents={invalidRespondents} invalidGroup={invalidGroup} + invalidImport={invalidImport} projectId={projectId} unusedSample={unusedSample} questionnaires={activeQuestionnaires} @@ -124,6 +127,7 @@ const mapStateToProps = (state, ownProps) => ({ respondentGroupsUploadingExisting: state.respondentGroups.uploadingExisting, invalidRespondents: state.respondentGroups.invalidRespondents, invalidGroup: state.respondentGroups.invalidRespondentsForGroup, + invalidImport: state.respondentGroups.invalidImport, survey: state.survey.data || {}, }) diff --git a/assets/js/components/surveys/SurveyForm.jsx b/assets/js/components/surveys/SurveyForm.jsx index b7b36f780..cf76952e0 100644 --- a/assets/js/components/surveys/SurveyForm.jsx +++ b/assets/js/components/surveys/SurveyForm.jsx @@ -36,6 +36,7 @@ class SurveyForm extends Component { respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, + invalidImport: PropTypes.object, channels: PropTypes.object, errors: PropTypes.object, readOnly: PropTypes.bool.isRequired, @@ -100,6 +101,7 @@ class SurveyForm extends Component { respondentGroupsUploadingExisting, invalidRespondents, invalidGroup, + invalidImport, errors, questionnaire, readOnly, @@ -320,6 +322,7 @@ class SurveyForm extends Component { respondentGroupsUploadingExisting={respondentGroupsUploadingExisting} invalidRespondents={invalidRespondents} invalidGroup={invalidGroup} + invalidImport={invalidImport} readOnly={readOnly} surveyStarted={surveyStarted} /> diff --git a/assets/js/components/surveys/SurveySettings.jsx b/assets/js/components/surveys/SurveySettings.jsx index c50b8ed96..2aadbfd4a 100644 --- a/assets/js/components/surveys/SurveySettings.jsx +++ b/assets/js/components/surveys/SurveySettings.jsx @@ -27,6 +27,7 @@ class SurveySettings extends Component { respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, + invalidImport: PropTypes.object, } componentWillMount() { @@ -56,6 +57,7 @@ class SurveySettings extends Component { respondentGroupsUploadingExisting, invalidRespondents, invalidGroup, + invalidImport, t, } = this.props @@ -81,6 +83,7 @@ class SurveySettings extends Component { respondentGroupsUploadingExisting={respondentGroupsUploadingExisting} invalidRespondents={invalidRespondents} invalidGroup={invalidGroup} + invalidImport={invalidImport} projectId={projectId} unusedSample={unusedSample} questionnaires={questionnaires} @@ -107,6 +110,7 @@ const mapStateToProps = (state, ownProps) => ({ respondentGroupsUploadingExisting: state.respondentGroups.uploadingExisting, invalidRespondents: state.respondentGroups.invalidRespondents, invalidGroup: state.respondentGroups.invalidRespondentsForGroup, + invalidImport: state.respondentGroups.invalidImport, survey: state.survey.data || {}, }) diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index ba84d095b..e04c0c13d 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -25,6 +25,7 @@ class SurveyWizardRespondentsStep extends Component { respondentGroupsUploadingExisting: PropTypes.object, invalidRespondents: PropTypes.object, invalidGroup: PropTypes.bool, + invalidImport: PropTypes.object, channels: PropTypes.object, actions: PropTypes.object.isRequired, readOnly: PropTypes.bool.isRequired, @@ -64,6 +65,12 @@ class SurveyWizardRespondentsStep extends Component { this.props.actions.clearInvalids() } + clearInvalidImport(e) { + e.preventDefault() + + this.props.actions.clearInvalidImport() + } + invalidEntriesText(invalidEntries, invalidEntryType) { const { t } = this.props const filteredInvalidEntries = invalidEntries.filter( @@ -148,6 +155,41 @@ class SurveyWizardRespondentsStep extends Component { ) } + importErrorMessage(errorCode, sourceSurveyId, data) { + const { t } = this.props + switch(errorCode) { + case "NO_SAMPLE": + return t("Survey #{{sourceSurveyId}} has no unused respondents to import", { sourceSurveyId }) + case "NOT_TERMINATED": + const surveyState = data.survey_state + return t("Survey #{{sourceSurveyId}} hasn't terminated - its state is {{surveyState}}", { sourceSurveyId, surveyState }) + case "INVALID_ENTRIES": + return this.invalidEntriesText(data.invalid_entries, "invalid-phone-number") + default: + return t("Error importing respondents from survey #{{sourceSurveyId}}", { sourceSurveyId }) + } + } + + importErrorContent(importError) { + const { surveyStarted, t } = this.props + if(!importError || surveyStarted) { + return null + } + + const { errorCode, sourceSurveyId, data } = importError + const errorMessage = this.importErrorMessage(errorCode, sourceSurveyId, data) + return +
+ {errorMessage} +
+
+ + } + channelChange(e, group, mode, allChannels) { e.preventDefault() @@ -347,11 +389,14 @@ class SurveyWizardRespondentsStep extends Component { respondentGroupsImporting, respondentGroupsUploadingExisting, invalidRespondents, + invalidImport, readOnly, surveyStarted, t, } = this.props + let uploading = respondentGroupsUploading || respondentGroupsImporting let invalidRespondentsCard = this.invalidRespondentsContent(invalidRespondents) + let importErrorCard = this.importErrorContent(invalidImport) if (!survey || !channels) { return
{t("Loading...")}
} @@ -359,7 +404,7 @@ class SurveyWizardRespondentsStep extends Component { const mode = survey.mode || [] const allModes = uniq(flatten(mode)) - let importUnusedSampleButton =
+ let importUnusedSampleButton = uploading ? null :
{t("Import unused respondents")} @@ -419,7 +464,7 @@ class SurveyWizardRespondentsStep extends Component { showCancel /> {invalidRespondentsCard || respondentsDropzone} - {importUnusedSampleButton} + {importErrorCard || importUnusedSampleButton} {importUnusedSampleModal} ) diff --git a/assets/js/reducers/respondentGroups.js b/assets/js/reducers/respondentGroups.js index 5f998ba7c..fcf0fc520 100644 --- a/assets/js/reducers/respondentGroups.js +++ b/assets/js/reducers/respondentGroups.js @@ -9,6 +9,7 @@ const initialState = { surveyId: null, invalidRespondents: null, invalidRespondentsForGroup: null, + invalidImport: null, } export default (state = initialState, action) => { @@ -29,6 +30,10 @@ export default (state = initialState, action) => { return receiveRespondentGroup(state, action) case actions.REMOVE_RESPONDENT_GROUP: return removeRespondentGroup(state, action) + case actions.INVALID_IMPORT: + return invalidImport(state, action) + case actions.CLEAR_INVALID_IMPORT: + return clearInvalidImport(state, action) case actions.INVALID_RESPONDENTS: return receiveInvalids(state, action) case actions.CLEAR_INVALIDS: @@ -70,6 +75,24 @@ const importRespondentGroup = (state, action) => { } } +const invalidImport = (state, action) => { + return { + ...state, + uploading: false, + importing: false, + invalidImport: action.importError + } +} + +const clearInvalidImport = (state, action) => { + return { + ...state, + invalidImport: null, + uploading: false, + importing: false, + } +} + const uploadExistingRespondentGroup = (state, action) => { return { ...state, diff --git a/lib/ask_web/controllers/respondent_group_controller.ex b/lib/ask_web/controllers/respondent_group_controller.ex index bfa369de3..52b1f4764 100644 --- a/lib/ask_web/controllers/respondent_group_controller.ex +++ b/lib/ask_web/controllers/respondent_group_controller.ex @@ -57,12 +57,11 @@ defmodule AskWeb.RespondentGroupController do |> Repo.get!(source_survey_id) if !Survey.terminated?(source_survey) do - Logger.warn("Can't import sample from survey ##{source_survey.id} - state is #{source_survey.state} instead of terminated") - render_unprocessable_entity(conn) + render_invalid_import(conn, source_survey.id, "NOT_TERMINATED", %{survey_state: source_survey.state}) else entries = unused_respondents_from_survey(source_survey) - if entries do + if !Enum.empty?(entries) do sample_name = "__imported_from_survey_#{source_survey.id}.csv" case RespondentGroupAction.load_entries(entries, survey) do {:ok, loaded_entries} -> @@ -75,11 +74,13 @@ defmodule AskWeb.RespondentGroupController do |> render("show.json", respondent_group: respondent_group) {:error, invalid_entries} -> - render_invalid(conn, sample_name, invalid_entries) + # I don't see how numbers that were valid in a terminated survey would now be invalid when + # importing them into another survey, but we'll handle that just in case - and `loaded_entries` + # requires that, anyways + render_invalid_import(conn, source_survey.id, "INVALID_ENTRIES", %{invalid_entries: invalid_entries}) end else - Logger.warn("Error when creating respondent group for survey: #{inspect(survey)}") - render_unprocessable_entity(conn) + render_invalid_import(conn, source_survey.id, "NO_SAMPLE") end end end @@ -213,10 +214,10 @@ defmodule AskWeb.RespondentGroupController do def unused_respondents_from_survey(survey) do from(r in Respondent, where: - r.survey_id == ^survey.id and r.disposition == :registered + r.survey_id == ^survey.id and r.disposition == :registered, + select: r.phone_number ) |> Repo.all() - |> Enum.map(fn r -> r.phone_number end) end defp csv_rows(csv_string) do @@ -245,6 +246,16 @@ defmodule AskWeb.RespondentGroupController do |> render("invalid_entries.json", %{invalid_entries: invalid_entries, filename: filename}) end + defp render_invalid_import(conn, source_survey_id, error_code, data \\ %{}) do + conn + |> put_status(:unprocessable_entity) + |> render("invalid_import.json", %{ + source_survey_id: source_survey_id, + error_code: error_code, + data: data + }) + end + def delete(conn, %{"id" => id}) do project = conn.assigns.loaded_project survey = conn.assigns.loaded_survey diff --git a/lib/ask_web/views/respondent_group_view.ex b/lib/ask_web/views/respondent_group_view.ex index 3f06be1bb..3522ad319 100644 --- a/lib/ask_web/views/respondent_group_view.ex +++ b/lib/ask_web/views/respondent_group_view.ex @@ -56,4 +56,12 @@ defmodule AskWeb.RespondentGroupView do filename: filename } end + + def render("invalid_import.json", %{source_survey_id: source_survey_id, error_code: error_code, data: data}) do + %{ + sourceSurveyId: source_survey_id, + errorCode: error_code, + data: data + } + end end diff --git a/locales/template/translation.json b/locales/template/translation.json index 0017b7b5d..4f42b770f 100644 --- a/locales/template/translation.json +++ b/locales/template/translation.json @@ -171,6 +171,7 @@ "Enter delays like 10m 2h 1d to express time units (use values greater than 10m)": "", "Error ID:": "", "Error details": "", + "Error importing respondents from survey #{{sourceSurveyId}}": "", "Error message": "", "Error: CSV doesn't have a header for the primary language": "", "Error: CSV is empty": "", @@ -473,6 +474,8 @@ "Sun": "", "Surveda will sync available channels from these providers after user authorization": "", "Survey": "", + "Survey #{{sourceSurveyId}} has no unused respondents to import": "", + "Survey #{{sourceSurveyId}} hasn't terminated - its state is {{surveyState}}": "", "Survey results": "", "Surveys": "", "Target": "", From a291dd718198e7bf701c59d9a8c023a78bc94490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 20:30:17 -0300 Subject: [PATCH 11/18] Disable importing surveys without unused sample See #2389 --- assets/js/components/surveys/ImportSampleModal.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js index 8cc06dec5..547de17a1 100644 --- a/assets/js/components/surveys/ImportSampleModal.js +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -58,6 +58,13 @@ class ImportSampleModal extends Component {
{surveys.map((survey) => { let name = survey.name ? `${survey.name} (#${survey.survey_id})` : Untitled Survey #{survey.survey_id} + const canBeImported = survey.respondents > 0 + const importButton = canBeImported ? + ( this.onSubmit(e, survey.survey_id)} className="blue-text btn-flat"> + {t("Import")} + ) : ( e.preventDefault()} className="btn-flat disabled"> + {t("Import")} + ) return @@ -69,11 +76,7 @@ class ImportSampleModal extends Component { year="numeric" /> - + })} From b604cb1e6a09a93fafea742ef7bd42376f68b3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 21:10:37 -0300 Subject: [PATCH 12/18] Don't show Import sample for non-editable surveys See #2389 --- assets/js/components/surveys/SurveyWizardRespondentsStep.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index e04c0c13d..dbc25a217 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -404,7 +404,7 @@ class SurveyWizardRespondentsStep extends Component { const mode = survey.mode || [] const allModes = uniq(flatten(mode)) - let importUnusedSampleButton = uploading ? null :
+ let importUnusedSampleButton = (uploading || readOnly || surveyStarted) ? null :
{t("Import unused respondents")} From 52097d84523b2514af8b44780c5da9696a8acd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 21:23:25 -0300 Subject: [PATCH 13/18] Remove leftover comment --- lib/ask_web/controllers/survey_controller.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/ask_web/controllers/survey_controller.ex b/lib/ask_web/controllers/survey_controller.ex index e0989e42a..82338a1c0 100644 --- a/lib/ask_web/controllers/survey_controller.ex +++ b/lib/ask_web/controllers/survey_controller.ex @@ -59,10 +59,6 @@ defmodule AskWeb.SurveyController do render(conn, "index.json", surveys: surveys) end - # select s.id, count(1) - # from surveys s left join respondents r on r.survey_id = s.id - # where s.state = 'terminated' and r.disposition = 'registered' and s.project_id = 38 - # group by s.id; def list_unused(conn, %{"project_id" => project_id}) do project = load_project(conn, project_id) From cfb2db0d41a78f0b73827c01af5eda161fde626b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 21:23:39 -0300 Subject: [PATCH 14/18] Fix respondentGroups spec The state changed in both 28fa1a334fe3be140157e91a6244de66edf91113 and f1160f2593aa58bf1f96faff54c1b8cbf8fb5581 --- test/js/reducers/respondentGroups.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/reducers/respondentGroups.spec.js b/test/js/reducers/respondentGroups.spec.js index daefd83ed..118fcfce5 100644 --- a/test/js/reducers/respondentGroups.spec.js +++ b/test/js/reducers/respondentGroups.spec.js @@ -9,7 +9,7 @@ describe('respondents reducer', () => { const playActions = playActionsFromState(initialState, reducer) it('should handle initial state', () => { - expect(initialState).toEqual({fetching: false, surveyId: null, items: null, invalidRespondents: null, invalidRespondentsForGroup: null, uploading: false, uploadingExisting: {}}) + expect(initialState).toEqual({fetching: false, surveyId: null, items: null, invalidRespondents: null, invalidRespondentsForGroup: null, invalidImport: null, uploading: false, importing: false, uploadingExisting: {}}) }) it('should start fetching respondent group', () => { From 5c97de16de060267e9fc596c1f44f8e063c8f900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 21:38:14 -0300 Subject: [PATCH 15/18] Remove unused constructor It was copied from another modal, but then turned up unnecessary. See #2389 --- assets/js/components/surveys/ImportSampleModal.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js index 547de17a1..2c0e14a9d 100644 --- a/assets/js/components/surveys/ImportSampleModal.js +++ b/assets/js/components/surveys/ImportSampleModal.js @@ -14,14 +14,6 @@ class ImportSampleModal extends Component { style: PropTypes.object, } - constructor(props) { - super(props) - this.state = { - buckets: {}, - steps: {}, - } - } - onSubmit(event, selectedSurveyId) { event.preventDefault() let { onConfirm, modalId } = this.props From 7bec9cb7fea64ebb36bde74404417e0663e9357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Thu, 28 Aug 2025 21:51:08 -0300 Subject: [PATCH 16/18] Fix flow types See #2389 --- assets/js/actions/surveys.js | 4 ++-- assets/js/decls/store.js | 7 +++++++ assets/js/reducers/unusedSample.js | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/assets/js/actions/surveys.js b/assets/js/actions/surveys.js index 8423a5bc9..bfda6f8b2 100644 --- a/assets/js/actions/surveys.js +++ b/assets/js/actions/surveys.js @@ -33,7 +33,7 @@ export const fetchSurveys = .then(() => getState().surveys.items) } -export const fetchUnusedSample = (projectId: number) => (dispatch: Function, getState: () => Store): Promise => { +export const fetchUnusedSample = (projectId: number) => (dispatch: Function, getState: () => Store) => { dispatch(fetchingUnusedSampleSurveys()) return api.fetchUnusedSampleSurveys(projectId).then((surveys) => { dispatch(receiveUnusedSampleSurveys(surveys)) @@ -44,7 +44,7 @@ export const fetchingUnusedSampleSurveys = () => ({ type: FETCHING_UNUSED_SAMPLE_SURVEYS }) -export const receiveUnusedSampleSurveys = (surveys: Array) => ({ +export const receiveUnusedSampleSurveys = (surveys: [UnusedSampleSurvey]) => ({ type: RECEIVE_UNUSED_SAMPLE_SURVEYS, surveys }) diff --git a/assets/js/decls/store.js b/assets/js/decls/store.js index e37becef4..29c730bb7 100644 --- a/assets/js/decls/store.js +++ b/assets/js/decls/store.js @@ -108,3 +108,10 @@ export type ChannelList = ListStore export type ProjectList = ListStore & { filter: ?ArchiveFilter, } + +export type UnusedSampleSurvey = { + survey_id: number, + respondents: number, + name: string, + ended_at: string, +} diff --git a/assets/js/reducers/unusedSample.js b/assets/js/reducers/unusedSample.js index e6e661038..675478dc1 100644 --- a/assets/js/reducers/unusedSample.js +++ b/assets/js/reducers/unusedSample.js @@ -3,7 +3,7 @@ import * as actions from "../actions/surveys" const initialState = null -export default (state = initialState, action) => { +export default (state : ?[UnusedSampleSurvey] = initialState, action) => { switch (action.type) { case actions.FETCHING_UNUSED_SAMPLE_SURVEYS: return initialState @@ -14,7 +14,7 @@ export default (state = initialState, action) => { } } -const receiveUnusedSampleSurveys = (state: Array, action: any) => ( +const receiveUnusedSampleSurveys = (state: ?[UnusedSampleSurvey], action: any) => ( action.surveys ) From 34a4e79dfa3e571d4f16aedcf700a8fcaac5a99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Fri, 29 Aug 2025 17:58:42 -0300 Subject: [PATCH 17/18] Fix linter and flow types --- .../components/surveys/SurveyWizardRespondentsStep.jsx | 9 +++++---- assets/js/reducers/unusedSample.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx index dbc25a217..556821275 100644 --- a/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx +++ b/assets/js/components/surveys/SurveyWizardRespondentsStep.jsx @@ -28,6 +28,7 @@ class SurveyWizardRespondentsStep extends Component { invalidImport: PropTypes.object, channels: PropTypes.object, actions: PropTypes.object.isRequired, + surveysActions: PropTypes.object.isRequired, readOnly: PropTypes.bool.isRequired, surveyStarted: PropTypes.bool.isRequired, } @@ -39,8 +40,8 @@ class SurveyWizardRespondentsStep extends Component { showImportUnusedSampleModal(e) { e.preventDefault() - let { projectId, surveysActions} = this.props - surveysActions.fetchUnusedSample(projectId) + let { survey, surveysActions } = this.props + surveysActions.fetchUnusedSample(survey.projectId) $('#importUnusedSampleModal').modal("open") } @@ -406,7 +407,7 @@ class SurveyWizardRespondentsStep extends Component { let importUnusedSampleButton = (uploading || readOnly || surveyStarted) ? null :
@@ -414,7 +415,7 @@ class SurveyWizardRespondentsStep extends Component { let importUnusedSampleModal = this.importUnusedSample(e)} modalId="importUnusedSampleModal" /> diff --git a/assets/js/reducers/unusedSample.js b/assets/js/reducers/unusedSample.js index 675478dc1..c40c4b3f1 100644 --- a/assets/js/reducers/unusedSample.js +++ b/assets/js/reducers/unusedSample.js @@ -3,7 +3,7 @@ import * as actions from "../actions/surveys" const initialState = null -export default (state : ?[UnusedSampleSurvey] = initialState, action) => { +export default (state : ?[UnusedSampleSurvey] = initialState, action: any) => { switch (action.type) { case actions.FETCHING_UNUSED_SAMPLE_SURVEYS: return initialState From 97fb7b112d8697c38a37a8fb93148a4214d84ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Mon, 1 Sep 2025 18:42:19 -0300 Subject: [PATCH 18/18] Explain why we're "filtering" this way See #2391 See #2389 --- lib/ask_web/controllers/survey_controller.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/ask_web/controllers/survey_controller.ex b/lib/ask_web/controllers/survey_controller.ex index 82338a1c0..c2b012eb3 100644 --- a/lib/ask_web/controllers/survey_controller.ex +++ b/lib/ask_web/controllers/survey_controller.ex @@ -68,6 +68,8 @@ defmodule AskWeb.SurveyController do left_join: r in Respondent, on: r.survey_id == s.id, where: s.project_id == ^project.id and s.state == :terminated, + # we could mix a `count` with a `where` clause filtering for respondent disposition + # instead of doing the sum+if, but that wouldn't return surveys with 0 respondents available select: %{survey_id: s.id, name: s.name, ended_at: s.ended_at, respondents: sum(fragment("if(?, ?, ?)", r.disposition == :registered, 1, 0))}, group_by: [s.id] ) |> Enum.map(fn s -> %{
{name}{survey.respondents} + +
{t("Name")}{t("Unused respondents")}{t("Survey ID")}{t("Unused respondents")}{t("Ended at")}
{name}{unusedSampleCount}
{name}{survey.respondents} - -
{t("Name")}{t("Unused respondents")}{t("Ended at")}
{name}{survey.respondents} + +
{t("Name")}{t("Unused respondents")}{t("Ended at")}
{t("Name")} {t("Unused respondents")} {t("Ended at")}
{name} {survey.respondents} @@ -65,6 +69,11 @@ class ImportSampleModal extends Component { year="numeric" /> + this.onSubmit(e, survey.survey_id)} className="blue-text btn-flat"> + {t("Import")} + +
{name} {survey.respondents} - this.onSubmit(e, survey.survey_id)} className="blue-text btn-flat"> - {t("Import")} - - {importButton}