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 } +
+ + {t("Cancel")} + +
+
+
+ ) + } +} + +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} +
+
+ this.clearInvalidImport(e)}> + {t("Understood")} + +
+
+ } + 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 :
+
+ this.showImportUnusedSampleModal(e)} className="btn-flat btn-flat-link"> + {t("Import unused respondents")} + +
+
+ + 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', () => {