diff --git a/.storybook/bundle.ts b/.storybook/bundle.ts index c7dd58daae..76649c5398 100644 --- a/.storybook/bundle.ts +++ b/.storybook/bundle.ts @@ -13,4 +13,4 @@ import FilterEditor from '../src/components/FilterEditor.vue'; import ListUniqueValues from '../src/components/ListUniqueValues.vue'; export { FilterEditor, ConditionsEditor, DataViewer, RenameStepForm, Pipeline, ResizablePanels, Step, ListUniqueValues }; -export { setupStore } from '../src/store'; +export { setupStore, registerModule, VQBnamespace } from '../src/store'; diff --git a/CHANGELOG.md b/CHANGELOG.md index ae729fbc79..50c8cf270b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +# Unreleased + +### Changed + +- Add a `isUniqueValuesLoading` key to columns in the vuex state. This will allow us to display a spinner when loading column's unique values. + +### Added + +- New component `ListUniqueValues` which will be used to display column's unique values. + +### Fixed + +- Fix setup of store in Storybook + ## [0.15.1] - 2020-04-06 ### Changed diff --git a/src/components/ListUniqueValues.vue b/src/components/ListUniqueValues.vue index 45741d5fc0..6c33e1313f 100644 --- a/src/components/ListUniqueValues.vue +++ b/src/components/ListUniqueValues.vue @@ -6,21 +6,24 @@ Select all   Clear all -
- -
-
-
List maybe incomplete
-
- Load all values +
+
+
+ +
+
+
List maybe incomplete
+
+ Load all values +
@@ -31,6 +34,9 @@ import _union from 'lodash/union'; import { Component, Prop, Vue } from 'vue-property-decorator'; import { FilterConditionInclusion } from '@/lib/steps.ts'; +import { VQBModule } from '@/store'; + +import CheckboxWidget from './stepforms/widgets/Checkbox.vue'; // This type will be imported from @/lib/dataset/helpers.ts type ColumnValueStat = { @@ -46,7 +52,6 @@ type ColumnValueStat = { value: ['France', 'UK', 'Spain'] } */ -import CheckboxWidget from './stepforms/widgets/Checkbox.vue'; @Component({ name: 'list-unique-values', components: { CheckboxWidget }, @@ -70,6 +75,8 @@ export default class ListUniqueValues extends Vue { }) loaded!: boolean; + @VQBModule.Getter('isUniqueValuesLoading') isLoadingFunction!: (column: string) => boolean; + search = ''; isChecked(option: ColumnValueStat): boolean { @@ -138,7 +145,7 @@ export default class ListUniqueValues extends Vue { } loadAllValues() { - throw new Error('Not implemented'); + this.$emit('loadAllValues'); } } @@ -202,6 +209,23 @@ export default class ListUniqueValues extends Vue { .list-unique-values__load-all-values-button { text-decoration: underline; font-weight: 700; + cursor: pointer; + } +} + +.list-unique-values__loader-spinner { + border-radius: 50%; + border: 4px solid #efefef; + border-top-color: $active-color; + width: 30px; + height: 30px; + animation: spin 1500ms ease-in-out infinite; + margin: 30px auto; +} + +@keyframes spin { + to { + transform: rotate(1turn); } } diff --git a/src/lib/dataset/index.ts b/src/lib/dataset/index.ts index 93cb39d589..eb47bbf362 100644 --- a/src/lib/dataset/index.ts +++ b/src/lib/dataset/index.ts @@ -12,6 +12,11 @@ export type ColumnTypeMapping = { export type DataSetColumn = { name: string; type?: DataSetColumnType; + /** + * Indicates if there's an ongoing operation to fetch all unique values for this column from the database. + * This value is updated by the `backendify` function. + */ + isUniqueValuesLoading?: boolean; }; /** diff --git a/src/store/getters.ts b/src/store/getters.ts index 9c4fc2b5bc..ed0ab30f82 100644 --- a/src/store/getters.ts +++ b/src/store/getters.ts @@ -4,6 +4,8 @@ import _ from 'lodash'; import { GetterTree } from 'vuex'; +import { DataSetColumn } from '@/lib/dataset/index.ts'; + import { activePipeline, currentPipeline, inactivePipeline, VQBState } from './state'; const getters: GetterTree = { @@ -82,6 +84,11 @@ const getters: GetterTree = { */ pageno: (state: VQBState) => state.dataset.paginationContext ? state.dataset.paginationContext.pageno : 1, + /** + * helper that is True if unique values are loading + */ + isUniqueValuesLoading: (state: VQBState) => (column: string) => + (state.dataset.headers.find(hdr => hdr.name === column) as DataSetColumn).isUniqueValuesLoading, /** * Return current edited pipeline */ diff --git a/src/store/mutations.ts b/src/store/mutations.ts index f1b320cbc2..48eaf1abc4 100644 --- a/src/store/mutations.ts +++ b/src/store/mutations.ts @@ -6,6 +6,7 @@ import Vue from 'vue'; import { MutationTree } from 'vuex'; import { BackendError } from '@/lib/backend-response'; +import { DataSetColumn } from '@/lib/dataset/index.ts'; import { DomainStep, Pipeline, PipelineStepName } from '@/lib/steps'; import { currentPipeline, VQBState } from './state'; @@ -284,6 +285,18 @@ class Mutations { state.isLoading = isLoading; } + /** + * Set unique values loading + */ + setUniqueValuesLoading( + state: VQBState, + { isLoading, column }: { isLoading: boolean; column: string }, + ) { + (state.dataset.headers.find( + hdr => hdr.name === column, + ) as DataSetColumn).isUniqueValuesLoading = isLoading; + } + /** * Update translator. */ diff --git a/stories/data-viewer.js b/stories/data-viewer.js index 0bad28c102..7fd343a06e 100644 --- a/stories/data-viewer.js +++ b/stories/data-viewer.js @@ -1,10 +1,29 @@ import { storiesOf } from '@storybook/vue'; +import Vuex from 'vuex'; +import Vue from 'vue'; + +import { DataViewer, registerModule } from '../dist/storybook/components'; -import { DataViewer, setupStore } from '../dist/storybook/components'; const stories = storiesOf('DataViewer', module); +Vue.use(Vuex) stories.add('empty', () => ({ - store: setupStore({}, [], true), + store: new Vuex.Store({}), + created: function() { + registerModule(this.$store, { + dataset: { + headers:[], + data: [], + paginationContext: { + pagesize: 50, + pageno: 1, + totalCount: 0, + }, + }, + currentPipelineName: "test", + pipelines: {test: []} + }) + }, components: { DataViewer }, template: ` @@ -13,28 +32,36 @@ stories.add('empty', () => ({ })); stories.add('simple', () => ({ - store: setupStore({ - dataset: { - headers: - [ - { name: 'columnA' }, - { name: 'columnB' }, - { name: 'columnC' }, + store: new Vuex.Store({}), + created: function() { + registerModule(this.$store, { + dataset: { + headers: + [ + { name: 'columnA', type: 'string' }, + { name: 'columnB', type: 'date' }, + { name: 'columnC', type: 'integer' }, + ], + data: [ + ['value1', 'value2', 'value3'], + ['value4', 'value5', 'value6'], + ['value7', 'value8', 'value9'], + ['value10', 'value11', 'value12'], + ['value10', { obj: 'value14' }, null], ], - data: [ - ['value1', 'value2', 'value3'], - ['value4', 'value5', 'value6'], - ['value7', 'value8', 'value9'], - ['value10', 'value11', 'value12'], - ['value10', { obj: 'value14' }, null], - ] - }, - }, [], true), + paginationContext: { + pagesize: 50, + pageno: 1, + totalCount: 0, + }, + }, + currentPipelineName: "test", + pipelines: {test: []} + }); + }, components: { DataViewer }, template: ` - + `, })); diff --git a/stories/form-rename-step.js b/stories/form-rename-step.js deleted file mode 100644 index 4a9e25bcfa..0000000000 --- a/stories/form-rename-step.js +++ /dev/null @@ -1,38 +0,0 @@ -import { - storiesOf -} from '@storybook/vue'; - -import { - RenameStepForm, - setupStore -} from '../dist/storybook/components'; - -const stories = storiesOf('RenameStepForm', module); - -stories.add('simple', () => ({ - components: { - RenameStepForm - }, - store: setupStore({ - dataset: { - headers: [{ - name: 'columnA' - }, { - name: 'columnB' - }, { - name: 'columnC' - }], - data: [ - ['value1', 'value2', 'value3'], - ['value4', 'value5', 'value6'], - ['value7', 'value8', 'value9'], - ['value10', 'value11', 'value12'], - ['value13', 'value14', 'value15'], - ], - }, - }, [], true), - template: ` - - - `, -})); diff --git a/stories/index.js b/stories/index.js index 82d8946c2e..42267f1341 100644 --- a/stories/index.js +++ b/stories/index.js @@ -63,7 +63,6 @@ stories })); import './data-viewer'; -import './form-rename-step'; import './resizable-panel'; import './filter-editor'; import './list-unique-values'; diff --git a/stories/list-unique-values.js b/stories/list-unique-values.js index 1d135daf9b..fee7520dd8 100644 --- a/stories/list-unique-values.js +++ b/stories/list-unique-values.js @@ -1,22 +1,55 @@ import { storiesOf } from '@storybook/vue'; +import Vuex from 'vuex'; +import Vue from 'vue'; -import { ListUniqueValues } from '../dist/storybook/components'; +import { ListUniqueValues, registerModule, VQBnamespace } from '../dist/storybook/components'; const stories = storiesOf('ListUniqueValues', module); +Vue.use(Vuex) stories.add('default', () => ({ + store: new Vuex.Store({}), + created: function() { + registerModule(this.$store, { + dataset: { + headers: [{name: 'col1', isUniqueValuesLoading: false}], + data: [], + } + }); + }, components: { ListUniqueValues }, + methods: { + loadAllValues(){ + this.$store.commit(VQBnamespace('setUniqueValuesLoading'), { isLoading: true, column: 'col1' }); + // simulate the call to get all unique values of dataset: + setTimeout(() => { + this.options = [ + {value: 'Germany', count: 120}, + {value: 'US', count: 34}, + {value: 'Italy', count: 33}, + {value: 'China', count: 24}, + {value: 'France', count: 12}, + {value: 'Framboise', count: 10}, + {value: 'UK', count: 1}, + ]; + this.loaded = false; + this.$store.commit(VQBnamespace('setUniqueValuesLoading'), { isLoading: false, column: 'col1' }); + }, 3000); // the call take 3sec + } + }, data(){ return { options: [ - {value: 'France', count: 12}, - {value: 'UK', count: 1}, + {value: 'Italy', count: 23}, {value: 'China', count: 14}, + {value: 'France', count: 12}, {value: 'Framboise', count: 10}, - {value: 'Italy', count: 23}, + {value: 'UK', count: 1}, ], filter: { column: "col1", operator: "in", value: []}, - loaded: false, + loaded: true, } }, - template: `
`, + template: `
+ +
`, })); diff --git a/tests/unit/list-unique-values.spec.ts b/tests/unit/list-unique-values.spec.ts index 6b369ead0c..46a01c0069 100644 --- a/tests/unit/list-unique-values.spec.ts +++ b/tests/unit/list-unique-values.spec.ts @@ -1,31 +1,60 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount, Wrapper } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import ListUniqueValues from '@/components/ListUniqueValues.vue'; -import { FilterConditionInclusion } from '@/lib/steps.ts'; + +import { setupMockStore } from './utils'; const localVue = createLocalVue(); localVue.use(Vuex); describe('List Unique Value', () => { - const DUMMY_UNIQUE_VALUES = [ - { value: 'France', count: 10 }, - { value: 'Framboise', count: 9 }, - { value: 'UK', count: 4 }, - { value: 'Spain', count: 2 }, - ]; - let filter!: FilterConditionInclusion; - let wrapper: any; - + let wrapper: Wrapper; + /** + `shallowMountWrapper` is utility function for the `ListUniqueValues` component + It shallow mount the component with several options: + @param filterValue the value key of the prop `filter` + @param operator the operator key of the prop `filter` + @param loaded the `loaded` props of the component + @param isUniqueValuesLoading the store parameter indicating if column unique value is loading + + By default the wrapper is mounted with 4 options: "France", "Framboise", "Spain" and "UK" + - The checked values are "France" and "Spain" + - All uniques are loaded + */ + const shallowMountWrapper = ( + filterValue: string[] = ['France', 'Spain'], + operator: 'in' | 'nin' = 'in', + loaded = true, + isUniqueValuesLoading = false, + ): Wrapper => { + return shallowMount(ListUniqueValues, { + store: setupMockStore({ + dataset: { + headers: [{ name: 'col1', isUniqueValuesLoading }], + data: [], + }, + }), + localVue, + propsData: { + filter: { + column: 'col1', + operator, + value: filterValue, + }, + options: [ + { value: 'France', count: 10 }, + { value: 'Framboise', count: 9 }, + { value: 'UK', count: 4 }, + { value: 'Spain', count: 2 }, + ], + loaded, + }, + }); + }; beforeEach(() => { - filter = { - column: 'col1', - operator: 'in', - value: ['France', 'Spain'], - }; - wrapper = shallowMount(ListUniqueValues, { - propsData: { filter, options: DUMMY_UNIQUE_VALUES, loaded: true }, - }); + wrapper = shallowMountWrapper(); }); it('should display the list of unique values and how much time they are present in the whole dataset', () => { @@ -47,14 +76,7 @@ describe('List Unique Value', () => { }); it('should display the list of unique values and how much time they are present in the whole dataset (with "nin" operator)', () => { - filter = { - column: 'col1', - operator: 'nin', - value: ['Framboise', 'UK'], - }; - wrapper = shallowMount(ListUniqueValues, { - propsData: { filter, options: DUMMY_UNIQUE_VALUES, loaded: true }, - }); + wrapper = shallowMountWrapper(['France', 'Spain'], 'nin'); expect(wrapper.exists()).toBeTruthy(); const CheckboxWidgetArray = wrapper.findAll('CheckboxWidget-stub'); expect(CheckboxWidgetArray.length).toEqual(4); @@ -65,14 +87,7 @@ describe('List Unique Value', () => { }); it('should instantiate with correct value checked (with "nin" operator)', () => { - filter = { - column: 'col1', - operator: 'nin', - value: ['Framboise', 'UK'], - }; - wrapper = shallowMount(ListUniqueValues, { - propsData: { filter, options: DUMMY_UNIQUE_VALUES, loaded: true }, - }); + wrapper = shallowMountWrapper(['Framboise', 'UK'], 'nin'); const CheckboxWidgetArray = wrapper.findAll('CheckboxWidget-stub'); expect(CheckboxWidgetArray.at(0).vm.$props.value).toBeTruthy(); expect(CheckboxWidgetArray.at(1).vm.$props.value).toBeFalsy(); @@ -80,23 +95,27 @@ describe('List Unique Value', () => { expect(CheckboxWidgetArray.at(3).vm.$props.value).toBeTruthy(); }); + it('should instantiate without the spinner', async () => { + expect(wrapper.find('.list-unique-values__loader-spinner').exists()).toBeFalsy(); + }); + + it('should instantiate with the spinner', async () => { + wrapper = shallowMountWrapper(['France', 'Spain'], 'in', true, true); + expect(wrapper.find('.list-unique-values__loader-spinner').exists()).toBeTruthy(); + }); + describe('"load all values" message', () => { it('should instantiate with the "load all values" message', () => { expect(wrapper.find('.list-unique-values__load-all-values').exists()).toBeTruthy(); }); - it('should throw an error when click on "load all values"', async () => { - try { - await wrapper.find('.list-unique-values__load-all-values-button').trigger('click'); - } catch (e) { - expect(e).toEqual(Error('Not implemented')); - } + it('should emit "loadAllValues" when click on "load all values"', async () => { + await wrapper.find('.list-unique-values__load-all-values-button').trigger('click'); + expect(wrapper.emitted().loadAllValues.length).toBe(1); }); it('should not instantiate with the "load all values" message', () => { - wrapper = shallowMount(ListUniqueValues, { - propsData: { filter, options: DUMMY_UNIQUE_VALUES, loaded: false }, - }); + wrapper = shallowMountWrapper(['France', 'Spain'], 'in', false); expect(wrapper.find('.list-unique-values__load-all-values').exists()).toBeFalsy(); }); }); @@ -186,14 +205,7 @@ describe('List Unique Value', () => { describe('search box with "nin" operator', () => { beforeEach(async () => { - filter = { - column: 'col1', - operator: 'nin', - value: ['France', 'Spain'], - }; - wrapper = shallowMount(ListUniqueValues, { - propsData: { filter, options: DUMMY_UNIQUE_VALUES, loaded: true }, - }); + wrapper = shallowMountWrapper(['France', 'Spain'], 'nin'); const input = wrapper.find('.list-unique-values__search-box').element as HTMLInputElement; input.value = 'Fr'; // "Fr" like the start of "France" and "Framboise" await wrapper.find('.list-unique-values__search-box').trigger('input'); diff --git a/tests/unit/store.spec.ts b/tests/unit/store.spec.ts index 839bf4226e..6dc99ae39c 100644 --- a/tests/unit/store.spec.ts +++ b/tests/unit/store.spec.ts @@ -124,6 +124,24 @@ describe('getter tests', () => { }); }); + describe('column isUniqueValuesLoading tests', () => { + it('should return a boolean indicating if column unique values are loading types', () => { + const state = buildState({ + dataset: { + headers: [ + { name: 'col1', isUniqueValuesLoading: true }, + { name: 'col2', isUniqueValuesLoading: false }, + { name: 'col3' }, + ], + data: [], + }, + }); + expect(getters.isUniqueValuesLoading(state, {}, {}, {})('col1')).toEqual(true); + expect(getters.isUniqueValuesLoading(state, {}, {}, {})('col2')).toEqual(false); + expect(getters.isUniqueValuesLoading(state, {}, {}, {})('col3')).toBeUndefined(); + }); + }); + describe('domain extraction tests', () => { it('should not return anything if no pipeline is selected', function() { const state = buildState({}); @@ -538,4 +556,24 @@ describe('mutation tests', () => { }); expect(state.translator).toEqual('mongo40'); }); + + it('set isUniqueValuesLoading to true', () => { + const state = buildState({ + dataset: { + headers: [{ name: 'col1', isUniqueValuesLoading: false }, { name: 'col2' }], + data: [], + }, + }); + mutations.setUniqueValuesLoading(state, { + isLoading: true, + column: 'col1', + }); + expect(state.dataset.headers[0].isUniqueValuesLoading).toEqual(true); + + mutations.setUniqueValuesLoading(state, { + isLoading: true, + column: 'col2', + }); + expect(state.dataset.headers[1].isUniqueValuesLoading).toEqual(true); + }); });