diff --git a/src/components/ActionMenu.vue b/src/components/ActionMenu.vue
index d231bd3ae7..47cb506369 100644
--- a/src/components/ActionMenu.vue
+++ b/src/components/ActionMenu.vue
@@ -6,6 +6,7 @@
+
diff --git a/src/components/stepforms/ReplaceStepForm.vue b/src/components/stepforms/ReplaceStepForm.vue
new file mode 100644
index 0000000000..4d08eac33e
--- /dev/null
+++ b/src/components/stepforms/ReplaceStepForm.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/stepforms/WidgetToReplace.vue b/src/components/stepforms/WidgetToReplace.vue
new file mode 100644
index 0000000000..ff7b7aa169
--- /dev/null
+++ b/src/components/stepforms/WidgetToReplace.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/stepforms/index.ts b/src/components/stepforms/index.ts
index 1804cd374f..5ac824f520 100644
--- a/src/components/stepforms/index.ts
+++ b/src/components/stepforms/index.ts
@@ -10,6 +10,7 @@ import './FormulaStepForm.vue';
import './PercentageStepForm.vue';
import './PivotStepForm.vue';
import './RenameStepForm.vue';
+import './ReplaceStepForm.vue';
import './SelectColumnStepForm.vue';
import './TopStepForm.vue';
import './UnpivotStepForm.vue';
diff --git a/src/components/stepforms/schemas/index.ts b/src/components/stepforms/schemas/index.ts
index a5cef958a1..2bdad79c38 100644
--- a/src/components/stepforms/schemas/index.ts
+++ b/src/components/stepforms/schemas/index.ts
@@ -10,6 +10,7 @@ import formulaSchema from './formula';
import percentageBuildSchema from './percentage';
import pivotSchema from './pivot';
import renameBuildSchema from './rename';
+import replaceBuildSchema from './replace';
import selectSchema from './select';
import topBuildSchema from './top';
import unpivotSchema from './unpivot';
@@ -30,6 +31,7 @@ const factories: { [stepname: string]: buildSchemaType } = {
percentage: percentageBuildSchema,
pivot: pivotSchema,
rename: renameBuildSchema,
+ replace: replaceBuildSchema,
select: selectSchema,
top: topBuildSchema,
unpivot: unpivotSchema,
diff --git a/src/components/stepforms/schemas/replace.ts b/src/components/stepforms/schemas/replace.ts
new file mode 100644
index 0000000000..321f96356c
--- /dev/null
+++ b/src/components/stepforms/schemas/replace.ts
@@ -0,0 +1,58 @@
+import { StepFormType, addNotInColumnNamesConstraint } from './utils';
+
+const schema = {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ title: 'Replace step',
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ enum: ['replace'],
+ },
+ search_column: {
+ type: 'string',
+ items: {
+ type: 'string',
+ minLength: 1,
+ },
+ title: 'Search',
+ description: 'Columns in which to search values to replace',
+ attrs: {
+ placeholder: 'Enter a column',
+ },
+ },
+ new_column: {
+ type: 'string',
+ items: {
+ type: 'string',
+ minLength: 0,
+ },
+ title: 'Search',
+ description: 'Columns in which to search values to replace',
+ attrs: {
+ placeholder: 'Enter a column',
+ },
+ },
+ to_replace: {
+ type: 'array',
+ items: {
+ type: 'array',
+ items: {
+ type: ['string', 'number', 'boolean'],
+ minItems: 2,
+ maxItems: 2,
+ },
+ minItems: 1,
+ },
+ minItems: 1,
+ title: 'To replace',
+ description: 'Values to replace',
+ },
+ },
+ required: ['name', 'search_column', 'to_replace'],
+ additionalProperties: false,
+};
+
+export default function buildSchema(form: StepFormType) {
+ return addNotInColumnNamesConstraint(schema, 'new_column', form.columnNames);
+}
diff --git a/tests/unit/replace-step-form.spec.ts b/tests/unit/replace-step-form.spec.ts
new file mode 100644
index 0000000000..dbbb84585e
--- /dev/null
+++ b/tests/unit/replace-step-form.spec.ts
@@ -0,0 +1,298 @@
+import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
+import ReplaceStepForm from '@/components/stepforms/ReplaceStepForm.vue';
+import Vuex, { Store } from 'vuex';
+import { setupStore } from '@/store';
+import { Pipeline } from '@/lib/steps';
+import { VQBState } from '@/store/state';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+interface ValidationError {
+ dataPath: string;
+ keyword: string;
+}
+
+describe('Replace Step Form', () => {
+ let emptyStore: Store;
+ beforeEach(() => {
+ emptyStore = setupStore({});
+ });
+
+ it('should instantiate', () => {
+ const wrapper = shallowMount(ReplaceStepForm, { store: emptyStore, localVue, sync: false });
+ expect(wrapper.exists()).toBeTruthy();
+ expect(wrapper.vm.$data.stepname).toEqual('replace');
+ });
+
+ it('should have exactly 3 input components', () => {
+ const wrapper = shallowMount(ReplaceStepForm, { store: emptyStore, localVue, sync: false });
+ const columnPickerWrappers = wrapper.findAll('columnpicker-stub');
+ const inputTextWrappers = wrapper.findAll('widgetinputtext-stub');
+ const widgetListWrappers = wrapper.findAll('widgetlist-stub');
+ expect(columnPickerWrappers.length).toEqual(1);
+ expect(inputTextWrappers.length).toEqual(1);
+ expect(widgetListWrappers.length).toEqual(1);
+ });
+
+ it('should pass down "search_column" to ColumnPicker', () => {
+ const wrapper = shallowMount(ReplaceStepForm, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'test',
+ to_replace: [['foo', 'bar']],
+ },
+ };
+ },
+ });
+ expect(wrapper.find('columnpicker-stub').attributes().value).toEqual('test');
+ });
+
+ it('should pass down "new_column" to WidgetInputText', () => {
+ const wrapper = shallowMount(ReplaceStepForm, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'test',
+ new_column: 'newTest',
+ to_replace: [['foo', 'bar']],
+ },
+ };
+ },
+ });
+ expect(wrapper.find('widgetinputtext-stub').props().value).toEqual('newTest');
+ });
+
+ it('should pass down "to_replace" to WidgetList', () => {
+ const wrapper = shallowMount(ReplaceStepForm, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'test',
+ new_column: 'newTest',
+ to_replace: [['foo', 'bar']],
+ },
+ };
+ },
+ });
+ expect(wrapper.find('widgetlist-stub').props().value).toEqual([['foo', 'bar']]);
+ });
+
+ it('should pass down the default "to_replace" to WidgetList', () => {
+ const wrapper = shallowMount(ReplaceStepForm, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'test',
+ new_column: 'newTest',
+ to_replace: [],
+ },
+ };
+ },
+ });
+ expect(wrapper.find('widgetlist-stub').props().value).toEqual([[]]);
+ });
+
+ it('should report errors when the data is not valid', async () => {
+ const store = setupStore({
+ dataset: {
+ headers: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+ data: [],
+ },
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'foo',
+ to_replace: [['', '']],
+ new_column: 'bar',
+ },
+ };
+ },
+ });
+ wrapper.find('.widget-form-action__button--validate').trigger('click');
+ await localVue.nextTick();
+ const errors = wrapper.vm.$data.errors
+ .map((err: ValidationError) => ({ keyword: err.keyword, dataPath: err.dataPath }))
+ .sort((err1: ValidationError, err2: ValidationError) =>
+ err1.dataPath.localeCompare(err2.dataPath),
+ );
+ expect(errors).toEqual([{ keyword: 'columnNameAlreadyUsed', dataPath: '.new_column' }]);
+ });
+
+ it('should validate and emit "formSaved" when submitted data is valid', async () => {
+ const store = setupStore({
+ dataset: {
+ headers: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+ data: [],
+ },
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'foo',
+ to_replace: [['hello', 'hi']],
+ },
+ };
+ },
+ });
+ wrapper.find('.widget-form-action__button--validate').trigger('click');
+ await localVue.nextTick();
+ expect(wrapper.vm.$data.errors).toBeNull();
+ expect(wrapper.emitted()).toEqual({
+ formSaved: [
+ [
+ {
+ name: 'replace',
+ search_column: 'foo',
+ to_replace: [['hello', 'hi']],
+ },
+ ],
+ ],
+ });
+ });
+
+ it('should convert input value to integer when the column data type is integer', () => {
+ const store = setupStore({
+ dataset: {
+ headers: [{ name: 'columnA', type: 'integer' }],
+ data: [[null]],
+ },
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'columnA',
+ to_replace: [['0', '42']],
+ },
+ };
+ },
+ });
+ wrapper.find('.widget-form-action__button--validate').trigger('click');
+ expect(wrapper.vm.$data.errors).toBeNull();
+ expect(wrapper.emitted()).toEqual({
+ formSaved: [[{ name: 'replace', search_column: 'columnA', to_replace: [[0, 42]] }]],
+ });
+ });
+
+ it('should convert input value to float when the column data type is float', () => {
+ const store = setupStore({
+ dataset: {
+ headers: [{ name: 'columnA', type: 'float' }],
+ data: [[null]],
+ },
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'columnA',
+ to_replace: [['0', '42.3']],
+ },
+ };
+ },
+ });
+ wrapper.find('.widget-form-action__button--validate').trigger('click');
+ expect(wrapper.vm.$data.errors).toBeNull();
+ expect(wrapper.emitted()).toEqual({
+ formSaved: [[{ name: 'replace', search_column: 'columnA', to_replace: [[0, 42.3]] }]],
+ });
+ });
+
+ it('should convert input value to boolean when the column data type is boolean', () => {
+ const store = setupStore({
+ dataset: {
+ headers: [{ name: 'columnA', type: 'boolean' }],
+ data: [[null]],
+ },
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ data: () => {
+ return {
+ editedStep: {
+ name: 'replace',
+ search_column: 'columnA',
+ to_replace: [['false', 'true']],
+ },
+ };
+ },
+ });
+ wrapper.find('.widget-form-action__button--validate').trigger('click');
+ expect(wrapper.vm.$data.errors).toBeNull();
+ expect(wrapper.emitted()).toEqual({
+ formSaved: [[{ name: 'replace', search_column: 'columnA', to_replace: [[false, true]] }]],
+ });
+ });
+
+ it('should emit "cancel" event when edition is cancelled', async () => {
+ const wrapper = mount(ReplaceStepForm, { store: emptyStore, localVue, sync: false });
+ wrapper.find('.widget-form-action__button--cancel').trigger('click');
+ await localVue.nextTick();
+ expect(wrapper.emitted()).toEqual({
+ cancel: [[]],
+ });
+ });
+
+ it('should reset selectedStepIndex correctly on cancel depending on isStepCreation', () => {
+ const pipeline: Pipeline = [
+ { name: 'domain', domain: 'foo' },
+ { name: 'rename', oldname: 'foo', newname: 'bar' },
+ { name: 'rename', oldname: 'baz', newname: 'spam' },
+ { name: 'rename', oldname: 'tic', newname: 'tac' },
+ ];
+ const store = setupStore({
+ pipeline,
+ selectedStepIndex: 2,
+ });
+ const wrapper = mount(ReplaceStepForm, {
+ store,
+ localVue,
+ sync: false,
+ propsData: { isStepCreation: true },
+ });
+ wrapper.find('.widget-form-action__button--cancel').trigger('click');
+ expect(store.state.selectedStepIndex).toEqual(2);
+ wrapper.setProps({ isStepCreation: false });
+ wrapper.find('.widget-form-action__button--cancel').trigger('click');
+ expect(store.state.selectedStepIndex).toEqual(3);
+ });
+});
diff --git a/tests/unit/widget-to-replace.spec.ts b/tests/unit/widget-to-replace.spec.ts
new file mode 100644
index 0000000000..252850a441
--- /dev/null
+++ b/tests/unit/widget-to-replace.spec.ts
@@ -0,0 +1,52 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import WidgetToReplace from '@/components/stepforms/WidgetToReplace.vue';
+import Vuex, { Store } from 'vuex';
+import { setupStore } from '@/store';
+import { VQBState } from '@/store/state';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Widget WidgetToReplace', () => {
+ let emptyStore: Store;
+ beforeEach(() => {
+ emptyStore = setupStore({});
+ });
+
+ it('should instantiate', () => {
+ const wrapper = shallowMount(WidgetToReplace, { store: emptyStore, localVue, sync: false });
+ expect(wrapper.exists()).toBeTruthy();
+ });
+
+ it('should have exactly two WidgetInputText components', () => {
+ const wrapper = shallowMount(WidgetToReplace, { store: emptyStore, localVue, sync: false });
+ const widgetWrappers = wrapper.findAll('widgetinputtext-stub');
+ expect(widgetWrappers.length).toEqual(2);
+ });
+
+ it('should pass down the properties to the input components', () => {
+ const wrapper = shallowMount(WidgetToReplace, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ data: () => {
+ return { toReplace: ['foo', 'bar'] };
+ },
+ });
+ const widgetWrappers = wrapper.findAll('widgetinputtext-stub');
+ expect(widgetWrappers.at(0).props().value).toEqual('foo');
+ expect(widgetWrappers.at(1).props().value).toEqual('bar');
+ });
+
+ it('should emit "input" event on "toReplace" update', async () => {
+ const wrapper = shallowMount(WidgetToReplace, {
+ store: emptyStore,
+ localVue,
+ sync: false,
+ });
+ wrapper.setData({ toReplace: ['foo', 'bar'] });
+ await localVue.nextTick();
+ expect(wrapper.emitted().input).toBeDefined();
+ expect(wrapper.emitted().input[1]).toEqual([['foo', 'bar']]);
+ });
+});