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/actions/respondentGroups.js b/assets/js/actions/respondentGroups.js
index 503e4820d..a8cb0ebae 100644
--- a/assets/js/actions/respondentGroups.js
+++ b/assets/js/actions/respondentGroups.js
@@ -12,6 +12,9 @@ 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 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"
@@ -38,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,
@@ -62,6 +74,18 @@ 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))
+ 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 =
(projectId, surveyId, groupId, file) => (dispatch, getState) => {
dispatch(uploadingExistingRespondentGroup(groupId))
@@ -115,6 +139,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/actions/surveys.js b/assets/js/actions/surveys.js
index 29459caaf..bfda6f8b2 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) => {
+ dispatch(fetchingUnusedSampleSurveys())
+ return api.fetchUnusedSampleSurveys(projectId).then((surveys) => {
+ dispatch(receiveUnusedSampleSurveys(surveys))
+ })
+}
+
+export const fetchingUnusedSampleSurveys = () => ({
+ type: FETCHING_UNUSED_SAMPLE_SURVEYS
+})
+
+export const receiveUnusedSampleSurveys = (surveys: [UnusedSampleSurvey]) => ({
+ 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 01f519a81..6c4c94cb5 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -236,6 +236,14 @@ 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 })
+}
+
export const addMoreRespondentsToGroup = (projectId, surveyId, groupId, file) => {
return apiPostFile(
`projects/${projectId}/surveys/${surveyId}/respondent_groups/${groupId}/add`,
diff --git a/assets/js/components/surveys/ImportSampleModal.js b/assets/js/components/surveys/ImportSampleModal.js
new file mode 100644
index 000000000..2c0e14a9d
--- /dev/null
+++ b/assets/js/components/surveys/ImportSampleModal.js
@@ -0,0 +1,97 @@
+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 {
+ static propTypes = {
+ t: PropTypes.func,
+ unusedSample: PropTypes.array,
+ onConfirm: PropTypes.func.isRequired,
+ modalId: PropTypes.string.isRequired,
+ style: PropTypes.object,
+ }
+
+ onSubmit(event, selectedSurveyId) {
+ event.preventDefault()
+ let { onConfirm, modalId } = this.props
+ $(`#${modalId}`).modal("close")
+ onConfirm(selectedSurveyId)
+ }
+
+ render() {
+ const { unusedSample, modalId, style, t } = this.props
+
+ let surveys = values(unusedSample || {})
+
+ let loadingDiv =
+
+ let surveysTable =
+
+
+
+
+
+
+
+
+ | {t("Name")} |
+ {t("Unused respondents")} |
+ {t("Ended at")} |
+ |
+
+
+
+ {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
+ | {name} |
+ {survey.respondents} |
+
+
+ |
+ {importButton} |
+
})}
+
+
+
+ return (
+
+
+
+
+
{t("Import unused respondents")}
+
{t("You can import the respondents that haven't been contacted in finished surveys of the project")}
+
+
+ { unusedSample ? surveysTable : loadingDiv }
+
+
+
+ )
+ }
+}
+
+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..d9c4c14fc 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,
+ unusedSample: PropTypes.array,
surveyId: PropTypes.any.isRequired,
router: PropTypes.object.isRequired,
survey: PropTypes.object.isRequired,
@@ -23,9 +24,11 @@ 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,
+ invalidImport: PropTypes.object,
}
componentWillMount() {
@@ -55,14 +58,17 @@ class SurveyEdit extends Component {
survey,
projectId,
project,
+ unusedSample,
questionnaires,
dispatch,
channels,
respondentGroups,
respondentGroupsUploading,
+ respondentGroupsImporting,
respondentGroupsUploadingExisting,
invalidRespondents,
invalidGroup,
+ invalidImport,
t,
} = this.props
const activeQuestionnaires = Object.keys(questionnaires)
@@ -90,10 +96,13 @@ class SurveyEdit extends Component {
survey={survey}
respondentGroups={respondentGroups}
respondentGroupsUploading={respondentGroupsUploading}
+ respondentGroupsImporting={respondentGroupsImporting}
respondentGroupsUploadingExisting={respondentGroupsUploadingExisting}
invalidRespondents={invalidRespondents}
invalidGroup={invalidGroup}
+ invalidImport={invalidImport}
projectId={projectId}
+ unusedSample={unusedSample}
questionnaires={activeQuestionnaires}
channels={channels}
dispatch={dispatch}
@@ -108,14 +117,17 @@ class SurveyEdit extends Component {
const mapStateToProps = (state, ownProps) => ({
projectId: ownProps.params.projectId,
project: state.project.data,
+ unusedSample: state.unusedSample,
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,
+ 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 ce6588815..cf76952e0 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,
+ unusedSample: PropTypes.array,
survey: PropTypes.object.isRequired,
surveyId: PropTypes.any.isRequired,
router: PropTypes.object.isRequired,
@@ -31,9 +32,11 @@ 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,
+ invalidImport: PropTypes.object,
channels: PropTypes.object,
errors: PropTypes.object,
readOnly: PropTypes.bool.isRequired,
@@ -89,13 +92,16 @@ class SurveyForm extends Component {
const {
survey,
projectId,
+ unusedSample,
questionnaires,
channels,
respondentGroups,
respondentGroupsUploading,
+ respondentGroupsImporting,
respondentGroupsUploadingExisting,
invalidRespondents,
invalidGroup,
+ invalidImport,
errors,
questionnaire,
readOnly,
@@ -308,12 +314,15 @@ class SurveyForm extends Component {
diff --git a/assets/js/components/surveys/SurveySettings.jsx b/assets/js/components/surveys/SurveySettings.jsx
index 20fc217b9..2aadbfd4a 100644
--- a/assets/js/components/surveys/SurveySettings.jsx
+++ b/assets/js/components/surveys/SurveySettings.jsx
@@ -14,6 +14,7 @@ class SurveySettings extends Component {
t: PropTypes.func,
dispatch: PropTypes.func,
projectId: PropTypes.any.isRequired,
+ unusedSample: PropTypes.array,
surveyId: PropTypes.any.isRequired,
router: PropTypes.object.isRequired,
survey: PropTypes.object.isRequired,
@@ -22,9 +23,11 @@ class SurveySettings extends Component {
project: PropTypes.object,
respondentGroups: PropTypes.object,
respondentGroupsUploading: PropTypes.bool,
+ respondentGroupsImporting: PropTypes.bool,
respondentGroupsUploadingExisting: PropTypes.object,
invalidRespondents: PropTypes.object,
invalidGroup: PropTypes.bool,
+ invalidImport: PropTypes.object,
}
componentWillMount() {
@@ -44,14 +47,17 @@ class SurveySettings extends Component {
survey,
projectId,
project,
+ unusedSample,
questionnaires,
dispatch,
channels,
respondentGroups,
respondentGroupsUploading,
+ respondentGroupsImporting,
respondentGroupsUploadingExisting,
invalidRespondents,
invalidGroup,
+ invalidImport,
t,
} = this.props
@@ -73,10 +79,13 @@ class SurveySettings extends Component {
survey={survey}
respondentGroups={respondentGroups}
respondentGroupsUploading={respondentGroupsUploading}
+ respondentGroupsImporting={respondentGroupsImporting}
respondentGroupsUploadingExisting={respondentGroupsUploadingExisting}
invalidRespondents={invalidRespondents}
invalidGroup={invalidGroup}
+ invalidImport={invalidImport}
projectId={projectId}
+ unusedSample={unusedSample}
questionnaires={questionnaires}
channels={channels}
dispatch={dispatch}
@@ -91,14 +100,17 @@ class SurveySettings extends Component {
const mapStateToProps = (state, ownProps) => ({
projectId: ownProps.params.projectId,
project: state.project.data,
+ unusedSample: state.unusedSample,
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,
+ 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 8ab851393..556821275 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,13 +18,17 @@ class SurveyWizardRespondentsStep extends Component {
static propTypes = {
t: PropTypes.func,
survey: PropTypes.object,
+ unusedSample: PropTypes.array,
respondentGroups: PropTypes.object.isRequired,
respondentGroupsUploading: PropTypes.bool,
+ respondentGroupsImporting: PropTypes.bool,
respondentGroupsUploadingExisting: PropTypes.object,
invalidRespondents: PropTypes.object,
invalidGroup: PropTypes.bool,
+ invalidImport: PropTypes.object,
channels: PropTypes.object,
actions: PropTypes.object.isRequired,
+ surveysActions: PropTypes.object.isRequired,
readOnly: PropTypes.bool.isRequired,
surveyStarted: PropTypes.bool.isRequired,
}
@@ -32,6 +38,18 @@ class SurveyWizardRespondentsStep extends Component {
if (files.length > 0) actions.uploadRespondentGroup(survey.projectId, survey.id, files)
}
+ showImportUnusedSampleModal(e) {
+ e.preventDefault()
+ let { survey, surveysActions } = this.props
+ surveysActions.fetchUnusedSample(survey.projectId)
+ $('#importUnusedSampleModal').modal("open")
+ }
+
+ importUnusedSample(sourceSurveyId) {
+ const { survey, actions } = this.props
+ actions.importUnusedSampleFromSurvey(survey.projectId, survey.id, sourceSurveyId)
+ }
+
addMoreRespondents(groupId, file) {
const { survey, actions } = this.props
actions.addMoreRespondentsToGroup(survey.projectId, survey.id, groupId, file)
@@ -48,6 +66,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(
@@ -132,6 +156,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()
@@ -324,16 +383,21 @@ class SurveyWizardRespondentsStep extends Component {
render() {
let {
survey,
+ unusedSample,
channels,
respondentGroups,
respondentGroupsUploading,
+ 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...")}
}
@@ -341,12 +405,27 @@ class SurveyWizardRespondentsStep extends Component {
const mode = survey.mode || []
const allModes = uniq(flatten(mode))
+ let importUnusedSampleButton = (uploading || readOnly || surveyStarted) ? null :
+
+ let importUnusedSampleModal = this.importUnusedSample(e)}
+ modalId="importUnusedSampleModal"
+ />
+
let respondentsDropzone = null
if (!readOnly && !surveyStarted) {
respondentsDropzone = (
this.handleSubmit(file)}
onDropRejected={() => $("#invalidTypeFile").modal("open")}
/>
@@ -386,6 +465,8 @@ class SurveyWizardRespondentsStep extends Component {
showCancel
/>
{invalidRespondentsCard || respondentsDropzone}
+ {importErrorCard || importUnusedSampleButton}
+ {importUnusedSampleModal}
)
}
@@ -393,6 +474,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/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/i18nStyle.jsx b/assets/js/i18nStyle.jsx
index 500b09fbd..877fcb1a9 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")}';
}
@@ -26,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/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/respondentGroups.js b/assets/js/reducers/respondentGroups.js
index f1aa6829a..fcf0fc520 100644
--- a/assets/js/reducers/respondentGroups.js
+++ b/assets/js/reducers/respondentGroups.js
@@ -3,11 +3,13 @@ import * as actions from "../actions/respondentGroups"
const initialState = {
fetching: false,
uploading: false,
+ importing: false,
uploadingExisting: {},
items: null,
surveyId: null,
invalidRespondents: null,
invalidRespondentsForGroup: null,
+ invalidImport: null,
}
export default (state = initialState, action) => {
@@ -16,6 +18,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:
@@ -26,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:
@@ -59,6 +67,32 @@ const uploadRespondentGroup = (state, action) => {
}
}
+const importRespondentGroup = (state, action) => {
+ return {
+ ...state,
+ uploading: true,
+ importing: true,
+ }
+}
+
+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,
@@ -89,6 +123,7 @@ const receiveRespondentGroups = (state, action) => {
...state,
fetching: false,
uploading: false,
+ importing: false,
items: respondentGroups,
invalidRespondents: null,
}
@@ -100,6 +135,7 @@ const receiveRespondentGroup = (state, action) => {
...state,
fetching: false,
uploading: false,
+ importing: false,
items: {
...state.items,
[group.id]: group,
@@ -128,6 +164,7 @@ const clearInvalids = (state, action) => {
...state,
invalidRespondents: null,
uploading: false,
+ importing: false,
}
}
diff --git a/assets/js/reducers/unusedSample.js b/assets/js/reducers/unusedSample.js
new file mode 100644
index 000000000..c40c4b3f1
--- /dev/null
+++ b/assets/js/reducers/unusedSample.js
@@ -0,0 +1,20 @@
+// @flow
+import * as actions from "../actions/surveys"
+
+const initialState = null
+
+export default (state : ?[UnusedSampleSurvey] = initialState, action: any) => {
+ 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: ?[UnusedSampleSurvey], action: any) => (
+ action.surveys
+)
+
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..52b1f4764 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,44 @@ 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)
+
+ if !Survey.terminated?(source_survey) do
+ render_invalid_import(conn, source_survey.id, "NOT_TERMINATED", %{survey_state: source_survey.state})
+ else
+ entries = unused_respondents_from_survey(source_survey)
+
+ if !Enum.empty?(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} ->
+ # 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
+ render_invalid_import(conn, source_survey.id, "NO_SAMPLE")
+ end
+ end
+ end
+
def update(conn, %{"id" => id, "respondent_group" => respondent_group_params}) do
survey = conn.assigns.loaded_survey
@@ -173,6 +211,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,
+ select: r.phone_number
+ )
+ |> Repo.all()
+ end
+
defp csv_rows(csv_string) do
csv_string
|> String.splitter(["\r\n", "\r", "\n"])
@@ -199,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/controllers/survey_controller.ex b/lib/ask_web/controllers/survey_controller.ex
index 1f812ad5b..c2b012eb3 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,30 @@ defmodule AskWeb.SurveyController do
render(conn, "index.json", surveys: surveys)
end
+ 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,
+ # 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 -> %{
+ 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 19cd72bdc..835b8a844 100644
--- a/lib/ask_web/router.ex
+++ b/lib/ask_web/router.ex
@@ -110,6 +110,7 @@ defmodule AskWeb.Router do
post "/add", RespondentGroupController, :add, as: :add
post "/replace", RespondentGroupController, :replace, as: :replace
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
@@ -128,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/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/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
diff --git a/locales/template/translation.json b/locales/template/translation.json
index 405c06edc..4f42b770f 100644
--- a/locales/template/translation.json
+++ b/locales/template/translation.json
@@ -164,12 +164,14 @@
"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": "",
"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": "",
@@ -193,7 +195,10 @@
"From": "",
"Generated {{surveyName}} {{reportType}} file": "",
"Ignored Values": "",
+ "Import": "",
"Import questionnaire": "",
+ "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": "",
@@ -234,6 +239,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...": "",
@@ -468,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": "",
@@ -521,6 +529,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}}": "",
@@ -556,6 +565,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": "",
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', () => {