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 @@
Rename column
Delete column
Fill null values
+
Replace values
Filter values
Sort values
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']]); + }); +});