diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb index 7f0d28e81..70432bc33 100644 --- a/app/controllers/api/v1/accounts/data_imports_controller.rb +++ b/app/controllers/api/v1/accounts/data_imports_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll DATA_IMPORT_FEATURE = 'data_import'.freeze before_action :ensure_data_import_feature_enabled - before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs] + before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs] before_action :check_authorization def index @@ -59,6 +59,24 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll render_show end + def retry_import + retry_service = DataImports::Intercom::RetryService.new(account: Current.account, data_import: @data_import) + retry_result = retry_service.perform + @data_import = retry_service.data_import + + case retry_result + when :enqueue + DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) + render_show + when :not_stalled + render json: { message: 'This Intercom import is no longer stalled.' }, status: :unprocessable_entity + when :active_import_exists + render json: { message: 'Another Intercom import is already in progress.' }, status: :unprocessable_entity + when :access_token_missing + render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity + end + end + def abandon @data_import.abandon! render_show diff --git a/app/javascript/dashboard/api/dataImports.js b/app/javascript/dashboard/api/dataImports.js index b4c15b98a..e6929d420 100644 --- a/app/javascript/dashboard/api/dataImports.js +++ b/app/javascript/dashboard/api/dataImports.js @@ -11,6 +11,10 @@ class DataImportsAPI extends ApiClient { return axios.post(`${this.url}/${id}/start`); } + retry(id) { + return axios.post(`${this.url}/${id}/retry`); + } + abandon(id) { return axios.post(`${this.url}/${id}/abandon`); } diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index ceb0438b1..6b3b63ea9 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -461,6 +461,7 @@ "STATUS": "Status", "IMPORTED": "Imported", "CREATED": "Created", + "RETRY": "Retry", "ABANDON": "Abandon" }, "DETAIL": { @@ -507,6 +508,8 @@ }, "ALERTS": { "IMPORT_STARTED": "Intercom import has started.", + "IMPORT_RETRIED": "Intercom import has been queued to resume.", + "IMPORT_RETRY_FAILED": "Could not retry the Intercom import.", "IMPORT_ABANDONED": "Intercom import has been abandoned.", "IMPORT_FAILED": "Could not start the Intercom import." } diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue index ed89c66a9..2bab87f75 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue @@ -26,6 +26,7 @@ const dataImport = ref(null); const isLoading = ref(true); const isRefreshing = ref(false); const isPolling = ref(false); +const isRetrying = ref(false); const isAbandoning = ref(false); const isDownloadingErrorLogs = ref(false); const isDownloadingSkipLogs = ref(false); @@ -117,6 +118,19 @@ const abandonImport = async () => { } }; +const retryImport = async () => { + isRetrying.value = true; + try { + const response = await DataImportsAPI.retry(dataImport.value.id); + dataImport.value = response.data; + useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_RETRIED')); + } catch { + useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_RETRY_FAILED')); + } finally { + isRetrying.value = false; + } +}; + const downloadCsv = (response, filename) => { const url = window.URL.createObjectURL( new Blob([response.data], { type: 'text/csv' }) @@ -197,9 +211,11 @@ onBeforeUnmount(() => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue index 935d63b87..66324a384 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue @@ -21,6 +21,10 @@ const props = defineProps({ type: Boolean, default: false, }, + isRetrying: { + type: Boolean, + default: false, + }, isAbandoning: { type: Boolean, default: false, @@ -31,7 +35,7 @@ const props = defineProps({ }, }); -defineEmits(['refresh', 'abandon']); +defineEmits(['refresh', 'retry', 'abandon']); const { t } = useI18n(); @@ -90,11 +94,23 @@ const canAbandonImport = computed(() => isAbandonableImport(props.dataImport)); :title="$t('DATA_IMPORTS.MONITOR.REFRESH')" @click="$emit('refresh')" /> + + `, +}; + +const BaseSettingsHeaderStub = { + template: ` +
+ + +
+ `, +}; + +const mountHeader = props => + mount(ImportDetailHeader, { + props, + global: { + stubs: { + Button: ButtonStub, + BaseSettingsHeader: BaseSettingsHeaderStub, + }, + mocks: { + $t: key => key, + }, + }, + }); + +describe('ImportDetailHeader', () => { + const activeImport = { + id: 1, + name: 'Intercom import', + data_type: 'intercom', + source_provider: 'intercom', + status: 'processing', + stalled: true, + }; + + it('shows Retry between Refresh and Abandon for stalled imports', async () => { + const wrapper = mountHeader({ dataImport: activeImport }); + const buttons = wrapper.findAll('button'); + + expect(buttons.map(button => button.attributes('data-label'))).toEqual([ + '', + 'DATA_IMPORTS.TABLE.RETRY', + 'DATA_IMPORTS.TABLE.ABANDON', + ]); + + await buttons[1].trigger('click'); + + expect(wrapper.emitted('retry')).toHaveLength(1); + }); + + it('hides Retry when the server does not report the import as stalled', () => { + const wrapper = mountHeader({ + dataImport: { ...activeImport, stalled: false }, + }); + + expect( + wrapper.find('[data-label="DATA_IMPORTS.TABLE.RETRY"]').exists() + ).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/showActions.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/showActions.spec.js new file mode 100644 index 000000000..eb7b83393 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/showActions.spec.js @@ -0,0 +1,104 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { KeepAlive, defineComponent, h, nextTick } from 'vue'; +import { useAlert } from 'dashboard/composables'; +import DataImportsAPI from 'dashboard/api/dataImports'; +import Show from '../Show.vue'; + +vi.mock('dashboard/api/dataImports', () => ({ + default: { + show: vi.fn(), + retry: vi.fn(), + }, +})); + +vi.mock('dashboard/composables', () => ({ + useAlert: vi.fn(), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRoute: () => ({ params: { dataImportId: 1 } }), +})); + +const SettingsLayoutStub = { + template: ` +
+ + +
+ `, +}; + +const ImportDetailHeaderStub = { + name: 'ImportDetailHeader', + emits: ['retry'], + template: '