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 @@
+
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' }]);
+ });
+});