diff --git a/doc/steps.md b/doc/steps.md index b517fb29a8..13e96a6f37 100644 --- a/doc/steps.md +++ b/doc/steps.md @@ -734,52 +734,16 @@ the `select` is used, it will only keep selected columns in the output. ### `sort` step Sort values in one or several columns. Order can be either 'asc' or 'desc'. -When sorting on several columns, order of columns specified in `columns` matters, -and the `order` parameter must be of same length as `columns`. By default, if -`order` is not specified, it is considered as 'asc'. +When sorting on several columns, order of columns specified in `columns` matters. ```javascript { name: 'sort', - columns: ['foo', 'bar'], - order: ['asc', 'desc'] + columns: [{column: 'foo', order: 'asc'}, {column: 'bar', order: 'desc'}], } ``` -#### Example 1: sort on one column with default ordering - -**Input dataset:** - -| Label | Group | Value | -| ------- | ------- | ----- | -| Label 1 | Group 1 | 13 | -| Label 2 | Group 1 | 7 | -| Label 3 | Group 1 | 20 | -| Label 4 | Group 2 | 1 | -| Label 5 | Group 2 | 10 | -| Label 6 | Group 2 | 5 | - -**Step configuration:** - -```javascript -{ - name: 'sort', - columns: ['Value'], -} -``` - -**Output dataset:** - -| Company | Group | Value | -| ------- | ------- | ----- | -| Label 4 | Group 2 | 1 | -| Label 6 | Group 2 | 5 | -| Label 2 | Group 1 | 7 | -| Label 5 | Group 2 | 10 | -| Label 1 | Group 1 | 13 | -| Label 3 | Group 1 | 20 | - -#### Example 2: sort on one column with default ordering +#### Example **Input dataset:** @@ -797,8 +761,7 @@ and the `order` parameter must be of same length as `columns`. By default, if ```javascript { name: 'sort', - columns: ['Group', 'Value'], - order: ['asc', 'desc'], + columns: [{ column: 'Group', order: 'asc'}, {column: 'Value', order: 'desc' }] } ``` diff --git a/src/components/ActionMenu.vue b/src/components/ActionMenu.vue index 5bac510d9b..d231bd3ae7 100644 --- a/src/components/ActionMenu.vue +++ b/src/components/ActionMenu.vue @@ -7,6 +7,7 @@
Delete column
Fill null values
Filter values
+
Sort values
diff --git a/src/components/stepforms/SortStepForm.vue b/src/components/stepforms/SortStepForm.vue new file mode 100644 index 0000000000..441ca4b282 --- /dev/null +++ b/src/components/stepforms/SortStepForm.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/stepforms/WidgetAggregation.vue b/src/components/stepforms/WidgetAggregation.vue index 589e424779..b5d1bf4216 100644 --- a/src/components/stepforms/WidgetAggregation.vue +++ b/src/components/stepforms/WidgetAggregation.vue @@ -76,10 +76,3 @@ export default class WidgetAggregation extends Vue { margin-bottom: 8px; } - - \ No newline at end of file diff --git a/src/components/stepforms/WidgetList.vue b/src/components/stepforms/WidgetList.vue index f1206dac25..3a061a7316 100644 --- a/src/components/stepforms/WidgetList.vue +++ b/src/components/stepforms/WidgetList.vue @@ -155,4 +155,15 @@ export default class WidgetList extends Vue { top: calc(50% - 16px); cursor: pointer; } - \ No newline at end of file + + diff --git a/src/components/stepforms/WidgetSortColumn.vue b/src/components/stepforms/WidgetSortColumn.vue new file mode 100644 index 0000000000..910a7f2acf --- /dev/null +++ b/src/components/stepforms/WidgetSortColumn.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/components/stepforms/index.ts b/src/components/stepforms/index.ts index 648b6d4cc5..faca525a66 100644 --- a/src/components/stepforms/index.ts +++ b/src/components/stepforms/index.ts @@ -12,3 +12,4 @@ import './RenameStepForm.vue'; import './SelectColumnStepForm.vue'; import './TopStepForm.vue'; import './UnpivotStepForm.vue'; +import './SortStepForm.vue'; diff --git a/src/components/stepforms/schemas/index.ts b/src/components/stepforms/schemas/index.ts index b919ac9512..8c231f501e 100644 --- a/src/components/stepforms/schemas/index.ts +++ b/src/components/stepforms/schemas/index.ts @@ -12,6 +12,7 @@ import renameBuildSchema from './rename'; import selectSchema from './select'; import topBuildSchema from './top'; import unpivotSchema from './unpivot'; +import sortSchema from './sort'; type buildSchemaType = ((form: any) => object) | object; @@ -30,6 +31,7 @@ const factories: { [stepname: string]: buildSchemaType } = { select: selectSchema, top: topBuildSchema, unpivot: unpivotSchema, + sort: sortSchema, }; /** diff --git a/src/components/stepforms/schemas/sort.ts b/src/components/stepforms/schemas/sort.ts new file mode 100644 index 0000000000..28411e76b4 --- /dev/null +++ b/src/components/stepforms/schemas/sort.ts @@ -0,0 +1,37 @@ +export default { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Sort column step', + type: 'object', + properties: { + name: { + type: 'string', + enum: ['sort'], + }, + columns: { + type: 'array', + minItems: 1, + title: 'Columns to sort', + description: 'Columns to sort', + attrs: { + placeholder: 'Enter columns', + }, + items: { + type: 'object', + title: 'Column to sort', + description: 'Column to sort', + properties: { + column: { + type: 'string', + minLength: 1, + }, + order: { + type: 'string', + enum: ['asc', 'desc'], + }, + }, + }, + }, + }, + required: ['name', 'columns'], + additionalProperties: false, +}; diff --git a/src/lib/steps.ts b/src/lib/steps.ts index fe58ce21bb..1b78997052 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -106,10 +106,14 @@ export type SelectStep = { columns: string[]; }; +export type SortColumnType = { + column: string; + order: 'asc' | 'desc'; +}; + export type SortStep = { name: 'sort'; - columns: string[]; - order?: ('asc' | 'desc')[]; + columns: SortColumnType[]; }; export type TopStep = { diff --git a/src/lib/translators/mongo.ts b/src/lib/translators/mongo.ts index 4e90ffc4ef..89af92e8cc 100644 --- a/src/lib/translators/mongo.ts +++ b/src/lib/translators/mongo.ts @@ -248,10 +248,8 @@ function transformReplace(step: Readonly): MongoStep { /** transform a 'sort' step into corresponding mongo steps */ function transformSort(step: Readonly): MongoStep { const sortMongo: PropMap = {}; - const sortOrders = step.order === undefined ? Array(step.columns.length).fill('asc') : step.order; - for (let i = 0; i < step.columns.length; i++) { - const order = sortOrders[i] === 'asc' ? 1 : -1; - sortMongo[step.columns[i]] = order; + for (const sortColumn of step.columns) { + sortMongo[sortColumn.column] = sortColumn.order === 'asc' ? 1 : -1; } return { $sort: sortMongo }; } diff --git a/tests/unit/karma-test-suite.ts b/tests/unit/karma-test-suite.ts index b5d595e52e..5845b58335 100644 --- a/tests/unit/karma-test-suite.ts +++ b/tests/unit/karma-test-suite.ts @@ -22,6 +22,7 @@ import './plugins.spec'; import './popover.spec'; import './rename-step-form.spec'; import './resizable-panels.spec'; +import './sort-step-form.spec'; import './step.spec'; import './store.spec'; import './top-step-form.spec'; @@ -33,4 +34,5 @@ import './widget-autocomplete.spec'; import './widget-checkbox.spec'; import './widget-input-text.spec'; import './widget-list.spec'; -import './widget-multiselect.spec.ts'; +import './widget-multiselect.spec'; +import './widget-sort-column.spec'; diff --git a/tests/unit/mongo.spec.ts b/tests/unit/mongo.spec.ts index f6c127b929..4439caff25 100644 --- a/tests/unit/mongo.spec.ts +++ b/tests/unit/mongo.spec.ts @@ -471,8 +471,7 @@ describe('Pipeline to mongo translator', () => { const pipeline: Pipeline = [ { name: 'sort', - columns: ['foo'], - order: ['desc'], + columns: [{ column: 'foo', order: 'desc' }], }, ]; const querySteps = mongo36translator.translate(pipeline); @@ -489,8 +488,7 @@ describe('Pipeline to mongo translator', () => { const pipeline: Pipeline = [ { name: 'sort', - columns: ['foo', 'bar'], - order: ['asc', 'desc'], + columns: [{ column: 'foo', order: 'asc' }, { column: 'bar', order: 'desc' }], }, ]; const querySteps = mongo36translator.translate(pipeline); @@ -504,24 +502,6 @@ describe('Pipeline to mongo translator', () => { ]); }); - it('can generate a sort step on multiple columns with default order', () => { - const pipeline: Pipeline = [ - { - name: 'sort', - columns: ['foo', 'bar'], - }, - ]; - const querySteps = mongo36translator.translate(pipeline); - expect(querySteps).to.eql([ - { - $sort: { - foo: 1, - bar: 1, - }, - }, - ]); - }); - it('can generate a fillna step', () => { const pipeline: Pipeline = [ { diff --git a/tests/unit/sort-step-form.spec.ts b/tests/unit/sort-step-form.spec.ts new file mode 100644 index 0000000000..0855f65312 --- /dev/null +++ b/tests/unit/sort-step-form.spec.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import SortStepForm from '@/components/stepforms/SortStepForm.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('Sort Step Form', () => { + let emptyStore: Store; + beforeEach(() => { + emptyStore = setupStore({}); + }); + + it('should instantiate', () => { + const wrapper = shallowMount(SortStepForm, { store: emptyStore, localVue }); + expect(wrapper.exists()).to.be.true; + }); + + context('WidgetList', () => { + it('should have one widgetList component', () => { + const wrapper = shallowMount(SortStepForm, { store: emptyStore, localVue }); + const widgetListWrapper = wrapper.findAll('widgetlist-stub'); + expect(widgetListWrapper.length).to.equal(1); + }); + + it('should pass the defaultSortColumn props to widgetList', async () => { + const wrapper = shallowMount(SortStepForm, { store: emptyStore, localVue }); + await localVue.nextTick(); + expect(wrapper.find('widgetlist-stub').props().value).to.eql([{ column: '', order: 'asc' }]); + }); + + it('should pass right sort props to widgetList sort column', async () => { + const wrapper = shallowMount(SortStepForm, { store: emptyStore, localVue }); + wrapper.setData({ + editedStep: { + name: 'sort', + columns: [{ column: 'amazing', order: 'desc' }], + }, + }); + await localVue.nextTick(); + expect(wrapper.find('widgetlist-stub').props().value).to.eql([ + { column: 'amazing', order: 'desc' }, + ]); + }); + }); + + context('Validation', () => { + it('should report errors when a column parameter is an empty string', async () => { + const wrapper = mount(SortStepForm, { store: emptyStore, localVue }); + wrapper.setData({ + editedStep: { + name: 'sort', + columns: [{ column: '', order: 'desc' }], + }, + }); + 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).to.eql([{ keyword: 'minLength', dataPath: '.columns[0].column' }]); + }); + + it('should validate and emit "formSaved" when submitted data is valid', async () => { + const wrapper = mount(SortStepForm, { store: emptyStore, localVue }); + wrapper.setData({ + editedStep: { + name: 'sort', + columns: [{ column: 'amazing', order: 'desc' }], + }, + }); + wrapper.find('.widget-form-action__button--validate').trigger('click'); + await localVue.nextTick(); + expect(wrapper.vm.$data.errors).to.be.null; + expect(wrapper.emitted()).to.eql({ + formSaved: [ + [ + { + name: 'sort', + columns: [{ column: 'amazing', order: 'desc' }], + }, + ], + ], + }); + }); + }); + + it('should emit "cancel" event when edition is cancelled', async () => { + const wrapper = mount(SortStepForm, { store: emptyStore, localVue }); + wrapper.find('.widget-form-action__button--cancel').trigger('click'); + await localVue.nextTick(); + expect(wrapper.emitted()).to.eql({ + 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(SortStepForm, { + store, + localVue, + propsData: { isStepCreation: true }, + }); + wrapper.find('.widget-form-action__button--cancel').trigger('click'); + expect(store.state.selectedStepIndex).to.equal(2); + wrapper.setProps({ isStepCreation: false }); + wrapper.find('.widget-form-action__button--cancel').trigger('click'); + expect(store.state.selectedStepIndex).to.equal(3); + }); +}); diff --git a/tests/unit/widget-sort-column.spec.ts b/tests/unit/widget-sort-column.spec.ts new file mode 100644 index 0000000000..450bfea2fe --- /dev/null +++ b/tests/unit/widget-sort-column.spec.ts @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import WidgetSortColumn from '@/components/stepforms/WidgetSortColumn.vue'; +import Vuex, { Store } from 'vuex'; +import { setupStore } from '@/store'; +import { VQBState } from '@/store/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Widget sort column', () => { + let emptyStore: Store; + beforeEach(() => { + emptyStore = setupStore({}); + }); + + it('should instantiate', () => { + const wrapper = shallowMount(WidgetSortColumn, { store: emptyStore, localVue }); + expect(wrapper.exists()).to.be.true; + }); + + it('should have exactly two WidgetAutocomplete components', () => { + const wrapper = shallowMount(WidgetSortColumn, { store: emptyStore, localVue }); + const widgetWrappers = wrapper.findAll('widgetautocomplete-stub'); + expect(widgetWrappers.length).to.equal(2); + }); + + it('should instantiate an widgetAutocomplete with proper options from the store', () => { + const store = setupStore({ + dataset: { + headers: [{ name: 'columnA' }, { name: 'columnB' }, { name: 'columnC' }], + data: [], + }, + }); + const wrapper = shallowMount(WidgetSortColumn, { store, localVue }); + const widgetWrappers = wrapper.findAll('widgetautocomplete-stub'); + expect(widgetWrappers.at(0).attributes('options')).to.equal('columnA,columnB,columnC'); + }); + + it('should pass down the "column" prop to the first WidgetAutocomplete value prop', async () => { + const wrapper = shallowMount(WidgetSortColumn, { store: emptyStore, localVue }); + wrapper.setProps({ value: { column: 'foo', order: 'asc' } }); + await localVue.nextTick(); + const widgetWrappers = wrapper.findAll('WidgetAutocomplete-stub'); + expect(widgetWrappers.at(0).props().value).to.equal('foo'); + }); + + it('should pass down the "order" prop to the second WidgetAutocomplete value prop', async () => { + const wrapper = shallowMount(WidgetSortColumn, { store: emptyStore, localVue }); + wrapper.setProps({ value: { column: 'foo', order: 'desc' } }); + await localVue.nextTick(); + const widgetWrappers = wrapper.findAll('WidgetAutocomplete-stub'); + expect(widgetWrappers.at(1).props().value).to.equal('desc'); + }); + + it('should emit "input" event on "value" update', async () => { + const wrapper = shallowMount(WidgetSortColumn, { + store: emptyStore, + localVue, + }); + wrapper.setProps({ value: { column: 'bar', order: 'desc' } }); + await localVue.nextTick(); + expect(wrapper.emitted().input).to.exist; + expect(wrapper.emitted().input[1]).to.eql([{ column: 'bar', order: 'desc' }]); + }); +});