diff --git a/CHANGELOG.md b/CHANGELOG.md index 0701fe3722503406ff178143a7fcd3d1f0e8968b..4ff9b7edf9bc36823916ec121ea2834da902790f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - The daily statistics data component was converted to Vue. - Time span controls were converted to use Vue. - Select-actions component converted to Vue. +- The hydrograph data table is now a Vue component and can be sorted by time. +- Parameter selection list now has the option to graph a second parameter ### Fixed - Parameter codes with multiple methods will now show statistical data for each method available. +- The hydrograph legend and time span shortcuts will now correctly display for calculated temperature parameter codes. ## [1.2.0](https://github.com/usgs/waterdataui/compare/waterdataui-1.1.0...waterdataui-1.2.0) - 2022-06-10 ### Added diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.js b/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.js index 7c3ed29d46f45ea916c78dfba2ad5331e2f7905c..945313a998ebcf7dddac0b71a6b69adf3abf0029 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.js @@ -43,8 +43,8 @@ const getLegendDisplay = createSelector( (showCompare, showMedian, thresholds, medianSeries, currentClasses, compareClasses, showFloodLevels, floodLevels, gwLevelKinds, primaryParameter) => { const parameterCode = primaryParameter ? primaryParameter.parameterCode : null; - const hasIVData = config.ivPeriodOfRecord && parameterCode ? parameterCode in config.ivPeriodOfRecord : false; - const hasGWLevelsData = config.gwPeriodOfRecord && parameterCode ? parameterCode in config.gwPeriodOfRecord : false; + const hasIVData = config.ivPeriodOfRecord && parameterCode ? parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE, '') in config.ivPeriodOfRecord : false; + const hasGWLevelsData = config.gwPeriodOfRecord && parameterCode ? parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE, '') in config.gwPeriodOfRecord : false; return { primaryIV: hasIVData ? currentClasses : undefined, compareIV: hasIVData && showCompare ? compareClasses : undefined, diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.test.js index 933ca67413a59738a50be00145202daeeed37a19..c1f802a0357e3f406a9341ad6956350ecc528ee2 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.test.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/selectors/legend-data.test.js @@ -6,7 +6,8 @@ import {getLegendMarkerRows} from './legend-data'; describe('monitoring-location/components/hydrograph/selectors/legend-data', () => { config.ivPeriodOfRecord = { - '72019': {} + '72019': {}, + '00010': {} }; config.gwPeriodOfRecord = { '72019': {} @@ -100,6 +101,23 @@ describe('monitoring-location/components/hydrograph/selectors/legend-data', () = } }; + const TEST_STATE_TEMPERATURE = { + ...TEST_STATE, + hydrographState: { + selectedParameterCode: '00010F', + showCompareIVData: false, + showMedianData: false, + selectedIVMethodID: '90649' + }, + primaryIVData: { + ...TEST_PRIMARY_IV_DATA, + parameter: { + parameterCode: '00010F', + unit: 'F' + } + } + }; + describe('getLegendMarkerRows', () => { beforeAll(() => { Object.defineProperty(window, 'matchMedia', { @@ -140,6 +158,16 @@ describe('monitoring-location/components/hydrograph/selectors/legend-data', () = expect(gwRow[2].type).toEqual(circleMarker); }); + it('Should return markers for primary IV with a caculated temperature parameter code', () => { + const markerRows = getLegendMarkerRows(TEST_STATE_TEMPERATURE); + expect(markerRows).toHaveLength(2); + const currentRow = markerRows[0]; + expect(currentRow).toHaveLength(3); + expect(currentRow[0].type).toEqual(textOnlyMarker); + expect(currentRow[1].type).toEqual(lineMarker); + expect(currentRow[2].type).toEqual(rectangleMarker); + }); + it('Should return markers for primary and compare when compare is visible', () => { const markerRows = getLegendMarkerRows({ ...TEST_STATE, diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/data-table.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/data-table.vue index f6c7000c00b926bcd0c2f4b633b42a6bec22ed95..aade0a8784142cc81b003d8b1fefe16db6691b95 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/data-table.vue +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/data-table.vue @@ -1,46 +1,26 @@ <template> <div id="iv-hydrograph-data-table-container"> <div - v-show="currentIVData.length" + v-show="currentIVData.IVData.length" id="iv-table-container" > - <table class="usa-table"> - <caption>Instantaneous value data</caption> - <thead> - <tr> - <th - v-for="column in ivColumnHeadings" - :key="column" - scope="col" - > - {{ column }} - </th> - </tr> - </thead> - <tbody class="list" /> - </table> - <ul class="pagination" /> + <PaginatedTable + :data-set="currentIVData.IVData" + :headers="ivColumnHeadings" + :key-order="ivValueNames" + :title="`${currentIVData.name} -- instantaneous value data`" + /> </div> <div - v-show="currentGWData.length" + v-show="currentGWData.GWData.length" id="gw-table-container" > - <table class="usa-table"> - <caption>Field visit data</caption> - <thead> - <tr> - <th - v-for="column in gwColumnHeadings" - :key="column" - scope="col" - > - {{ column }} - </th> - </tr> - </thead> - <tbody class="list" /> - </table> - <ul class="pagination" /> + <PaginatedTable + :data-set="currentGWData.GWData" + :headers="gwColumnHeadings" + :key-order="gwValueNames" + :title="`${currentGWData.name} -- field visit data`" + /> </div> <button @@ -76,12 +56,9 @@ </template> <script> -import Pagination from 'list.js/src/pagination.js'; /* eslint no-unused-vars: off */ -import List from 'list.js'; import {useState} from 'redux-connect-vue'; -import {ref, inject, onMounted} from 'vue'; - -import {listen} from 'ui/lib/d3-redux'; +import {ref} from 'vue'; +import {createSelector} from 'reselect'; import config from 'ui/config.js'; @@ -89,75 +66,62 @@ import {getIVTableData} from '../selectors/iv-data'; import {getGroundwaterLevelsTableData} from '../selectors/discrete-data'; import DownloadData from './download-data.vue'; +import PaginatedTable from './paginated-table.vue'; export default { name: 'DataTable', components: { - DownloadData + DownloadData, + PaginatedTable }, setup() { const downloadIcon = `${config.STATIC_URL}img/sprite.svg#file_download`; const showDownloadContainer = ref(false); - const CONTAINER_ID = { - iv: 'iv-table-container', - gw: 'gw-table-container' - }; const COLUMN_HEADINGS = { - iv: ['Parameter', 'Time', 'Result', 'Approval', 'Masks'], - gw: ['Parameter', 'Time', 'Result', 'Accuracy', 'Approval', 'Qualifiers'] + iv: ['Time', 'Result', 'Approval', 'Masks'], + gw: ['Time', 'Result', 'Accuracy', 'Approval', 'Qualifiers'] }; const VALUE_NAMES = { - iv: ['parameterName', 'dateTime', 'result', 'approvals', 'masks'], - gw: ['parameterName', 'dateTime', 'result', 'resultAccuracy', 'approvals', 'qualifiers'] + iv: ['dateTime', 'result', 'approvals', 'masks'], + gw: ['dateTime', 'result', 'resultAccuracy', 'approvals', 'qualifiers'] }; - const state = useState({ - currentIVData: getIVTableData('primary'), - currentGWData: getGroundwaterLevelsTableData - }); - let dataLists = { - iv: null, - gw: null - }; - const reduxStore = inject('store'); - - const updateDataTable = function(dataKind, data) { - if (dataLists[dataKind]) { - dataLists[dataKind].clear(); - dataLists[dataKind].add(data); - } else { - const items = VALUE_NAMES[dataKind].reduce(function(total, propName, index) { - if (index === 0) { - return `${total}<th scope="row" class="${propName}"></th>`; - } else { - return `${total}<td class="${propName}"></td>`; - } - }, ''); - const options = { - valueNames: VALUE_NAMES[dataKind], - item: `<tr>${items}</tr>`, - page: 16, - pagination: [{ - left: 1, - innerWindow: 2, - right: 1, - item: '<li><a class="page" href="javascript:;"></a></li>' - }] - }; - dataLists[dataKind] = new List(CONTAINER_ID[dataKind], options, data); + const filter = function(object, keys) { + let result = {}, key; + + for (key in object) { + if (Object.prototype.hasOwnProperty.call(object, key) && keys.includes(key)) { + result[key] = object[key]; + } } + return result; }; - const updateIVDataTable = function(data) { - updateDataTable('iv', data); - }; - const updateGWDataTable = function(data) { - updateDataTable('gw', data); - }; + const getIVData = createSelector( + getIVTableData('primary'), + (data) => { + + return { + IVData: data.map(object => filter(object, VALUE_NAMES.iv)), + name: data.length ? data[0].parameterName : '' + }; + } + ); - onMounted(() => { - listen(reduxStore, getIVTableData('primary'), updateIVDataTable); - listen(reduxStore, getGroundwaterLevelsTableData, updateGWDataTable); + const getGWData = createSelector( + getGroundwaterLevelsTableData, + (data) => { + + return { + GWData: data.map(object => filter(object, VALUE_NAMES.gw)), + name: data.length ? data[0].parameterName : '' + }; + } + ); + + const state = useState({ + currentIVData: getIVData, + currentGWData: getGWData }); const toggleDownloadContainer = function() { @@ -170,7 +134,9 @@ export default { showDownloadContainer, toggleDownloadContainer, ivColumnHeadings: COLUMN_HEADINGS.iv, - gwColumnHeadings: COLUMN_HEADINGS.gw + gwColumnHeadings: COLUMN_HEADINGS.gw, + ivValueNames: VALUE_NAMES.iv, + gwValueNames: VALUE_NAMES.gw }; } }; diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.test.js index 8074124233725accc6c25085124e2e47a569c9ba..1c617728c6cd05b5bcb26959b2611dbf40bd4ea4 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.test.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.test.js @@ -20,7 +20,8 @@ import GraphControls from './graph-controls.vue'; describe('monitoring-location/components/hydrograph/components/graph-controls', () => { utils.mediaQuery = jest.fn().mockReturnValue(true); config.ivPeriodOfRecord = { - '72019': {} + '72019': {}, + '00010': {} }; let restoreConsole; @@ -133,6 +134,38 @@ describe('monitoring-location/components/hydrograph/components/graph-controls', expect(wrapper.findAll('.usa-checkbox')[0].find('input').attributes('disabled')).toBeDefined(); }); + it('expect that compare is enabled if a calulated temp parameter code is selected', async() => { + store = configureStore({ + hydrographData: { + currentTimeRange: TEST_CURRENT_TIME_RANGE + }, + hydrographState: { + showCompareIVData: false, + selectedTimeSpan: 'P7D', + showMedianData: false, + selectedParameterCode: '00010F' + } + }); + + wrapper = mount(GraphControls, { + global: { + plugins: [ + [ReduxConnectVue, { + store, + mapDispatchToPropsFactory: (actionCreators) => (dispatch) => bindActionCreators(actionCreators, dispatch), + mapStateToPropsFactory: createStructuredSelector + }] + ], + provide: { + store: store, + siteno: '12345678' + } + } + }); + + expect(wrapper.findAll('.usa-checkbox')[0].find('input').attributes('disabled')).not.toBeDefined(); + }); + it('expect that clicking the median updates the store', async() => { const medianInput = wrapper.findAll('.usa-checkbox')[1].find('input'); medianInput.element.checked = true; diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.vue index 1ed7376dc9d3d5079ef299fc7b36dd6e6ca461e0..17d54673600f61a792b953f87e3b29f4fb0a847f 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.vue +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/graph-controls.vue @@ -44,7 +44,7 @@ export default { setup() { const hasIVData = function(parameterCode) { - return config.ivPeriodOfRecord && parameterCode in config.ivPeriodOfRecord; + return config.ivPeriodOfRecord && parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE, '') in config.ivPeriodOfRecord; }; const ALLOW_COMPARE_FOR_DURATIONS = config.IV_SHORTCUT_BUTTONS.map(button => button.timeSpan); @@ -104,6 +104,7 @@ export default { showDataIndicators(false, reduxStore); } } + return { ...state, selectCompare, diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.test.js new file mode 100644 index 0000000000000000000000000000000000000000..df64b1f159eeb90fd28c9a15d080e57ddc727f31 --- /dev/null +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.test.js @@ -0,0 +1,202 @@ +import {mount} from '@vue/test-utils'; + +import PaginatedTable from './paginated-table.vue'; + +describe('monitoring-location/components/hydrograph/components/data-table.vue', () => { + let wrapper; + const TEST_DATASET = [ + { + time: '12:00', + value: '3', + notes: 'sth 456' + }, + { + time: '12:15', + value: '453', + notes: 'sth 1' + }, + { + time: '12:30', + value: '123', + notes: 'sth 2' + }, + { + time: '12:45', + value: '35', + notes: 'sth 3' + }, + { + time: '13:00', + value: '33', + notes: 'sth 4' + }, + { + time: '13:15', + value: '1003', + notes: 'sth 5' + }, + { + time: '13:30', + value: '103', + notes: 'sth 6' + }, + { + time: '13:45', + value: '2', + notes: 'sth 7' + }, + { + time: '14:00', + value: '300', + notes: 'sth 8' + }, + { + time: '14:15', + value: '30', + notes: 'sth 9' + } + ]; + + it('Draws the table', () => { + wrapper = mount(PaginatedTable, { + props: { + dataSet: TEST_DATASET, + headers: ['Time', 'Result', 'Notes'], + rowsPerPage: 2, + title: 'Test Title', + keyOrder: ['time', 'value', 'notes'] + } + }); + const headers = wrapper.findAll('th'); + const rows = wrapper.findAll('tbody tr'); + expect(wrapper.find('caption').text()).toBe('Test Title'); + + expect(headers).toHaveLength(3); + expect(headers[0].text()).toBe('Time'); + expect(headers[1].text()).toBe('Result'); + expect(headers[2].text()).toBe('Notes'); + + expect(rows).toHaveLength(2); + expect(rows[0].findAll('td')).toHaveLength(3); + expect(rows[0].findAll('td')[0].text()).toBe('14:15'); + expect(rows[0].findAll('td')[1].text()).toBe('30'); + expect(rows[0].findAll('td')[2].text()).toBe('sth 9'); + + expect(rows[1].findAll('td')).toHaveLength(3); + expect(rows[1].findAll('td')[0].text()).toBe('14:00'); + expect(rows[1].findAll('td')[1].text()).toBe('300'); + expect(rows[1].findAll('td')[2].text()).toBe('sth 8'); + + expect(wrapper.findAll('nav')).toHaveLength(1); + }); + + it('Shows the pagination buttons', () => { + wrapper = mount(PaginatedTable, { + props: { + dataSet: TEST_DATASET, + headers: ['Time', 'Result', 'Notes'], + rowsPerPage: 2, + title: 'Test Title', + keyOrder: ['time', 'value', 'notes'] + } + }); + + const pageButtons = wrapper.findAll('ul li'); + expect(pageButtons).toHaveLength(4); + expect(pageButtons[0].text()).toBe('1'); + expect(pageButtons[1].text()).toBe('2'); + expect(pageButtons[2].text()).toBe('...'); + expect(pageButtons[3].text()).toBe('5'); + }); + + it('Changes the table when a page button is clicked', async() => { + wrapper = mount(PaginatedTable, { + props: { + dataSet: TEST_DATASET, + headers: ['Time', 'Result', 'Notes'], + rowsPerPage: 2, + title: 'Test Title', + keyOrder: ['time', 'value', 'notes'] + } + }); + const pageButtons = wrapper.findAll('ul li a'); + pageButtons[1].trigger('click'); + await wrapper.vm.$nextTick(); + const rows = wrapper.findAll('tbody tr'); + + expect(rows[0].findAll('td')).toHaveLength(3); + expect(rows[0].findAll('td')[0].text()).toBe('13:45'); + expect(rows[0].findAll('td')[1].text()).toBe('2'); + expect(rows[0].findAll('td')[2].text()).toBe('sth 7'); + + expect(rows[1].findAll('td')).toHaveLength(3); + expect(rows[1].findAll('td')[0].text()).toBe('13:30'); + expect(rows[1].findAll('td')[1].text()).toBe('103'); + expect(rows[1].findAll('td')[2].text()).toBe('sth 6'); + }); + + it('Updates page buttons when page is changed', async() => { + wrapper = mount(PaginatedTable, { + props: { + dataSet: TEST_DATASET, + headers: ['Time', 'Result', 'Notes'], + rowsPerPage: 2, + title: 'Test Title', + keyOrder: ['time', 'value', 'notes'] + } + }); + + let pageButtons = wrapper.findAll('ul li'); + expect(pageButtons).toHaveLength(4); + expect(pageButtons[0].text()).toBe('1'); + expect(pageButtons[1].text()).toBe('2'); + expect(pageButtons[2].text()).toBe('...'); + expect(pageButtons[3].text()).toBe('5'); + + wrapper.findAll('ul li a')[1].trigger('click'); + await wrapper.vm.$nextTick(); + pageButtons = wrapper.findAll('ul li'); + + expect(pageButtons).toHaveLength(5); + expect(pageButtons[0].text()).toBe('1'); + expect(pageButtons[1].text()).toBe('2'); + expect(pageButtons[2].text()).toBe('3'); + expect(pageButtons[3].text()).toBe('...'); + expect(pageButtons[4].text()).toBe('5'); + + wrapper.findAll('ul li a')[0].trigger('click'); + await wrapper.vm.$nextTick(); + pageButtons = wrapper.findAll('ul li'); + + expect(pageButtons).toHaveLength(4); + expect(pageButtons[0].text()).toBe('1'); + expect(pageButtons[1].text()).toBe('2'); + expect(pageButtons[2].text()).toBe('...'); + expect(pageButtons[3].text()).toBe('5'); + }); + + it('Sorts the table when the sort button is clicked', async() => { + wrapper = mount(PaginatedTable, { + props: { + dataSet: TEST_DATASET, + headers: ['Time', 'Result', 'Notes'], + rowsPerPage: 2, + title: 'Test Title', + keyOrder: ['time', 'value', 'notes'] + } + }); + const rows = wrapper.findAll('tbody tr'); + wrapper.find('svg').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(rows[0].findAll('td')).toHaveLength(3); + expect(rows[0].findAll('td')[0].text()).toBe('12:00'); + expect(rows[0].findAll('td')[1].text()).toBe('3'); + expect(rows[0].findAll('td')[2].text()).toBe('sth 456'); + + expect(rows[1].findAll('td')).toHaveLength(3); + expect(rows[1].findAll('td')[0].text()).toBe('12:15'); + expect(rows[1].findAll('td')[1].text()).toBe('453'); + expect(rows[1].findAll('td')[2].text()).toBe('sth 1'); + }); +}); \ No newline at end of file diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.vue new file mode 100644 index 0000000000000000000000000000000000000000..6ef917c1317be91638e10f292bf806d0ebb87d47 --- /dev/null +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/paginated-table.vue @@ -0,0 +1,216 @@ +<template> + <table class="usa-table"> + <!-- eslint-disable vue/no-v-html --> + <caption v-html="myTitle" /> + <!--eslint-enable--> + <thead> + <tr> + <th + v-for="column in myHeaders" + :key="column" + scope="col" + > + {{ column }} + <a href="javascript:void(0);"> + <svg + v-if="column === 'Time'" + class="usa-icon sort" + aria-hidden="true" + role="img" + @click="sortOrder === 'desc' ? sortOrder = 'asce' : sortOrder = 'desc'" + > + <use :xlink:href="sortIconURL" /> + </svg> + </a> + </th> + </tr> + </thead> + <tbody> + <tr + v-for="(dataObject, index) in pagedArray[page-1]" + :key="index" + > + <td + v-for="key in myKeyOrder" + :key="key" + :data-sort-value="key === 'dateTime' ? dataObject[key] : null" + > + {{ dataObject[key] }} + </td> + </tr> + </tbody> + <nav class="usa-pagination"> + <ul class="usa-pagination__list"> + <li class="usa-pagination__item usa-pagination__page-no"> + <a + :href="`${page === 1 ? '' : 'javascript:void(0);'}` || null" + :class="`usa-pagination__button ${page === 1 ? 'usa-current' : ''}`" + aria-label="Page 1" + @click="page=1" + > + {{ 1 }} + </a> + </li> + <li + v-if="page > 3" + class="usa-pagination__item usa-pagination__overflow" + > + <span>...</span> + </li> + <li + v-if="3 <= page && page <= data.length" + class="usa-pagination__item usa-pagination__page-no" + > + <a + class="usa-pagination__button" + :aria-label="`Page ${page - 1}`" + href="javascript:void(0);" + @click="page=page - 1" + > + {{ page - 1 }} + </a> + </li> + <li + v-if="1 < page && page < data.length" + class="usa-pagination__item usa-pagination__page-no" + > + <a + class="usa-pagination__button usa-current" + :aria-label="`Page ${page}`" + > + {{ page }} + </a> + </li> + <li + v-if="1 <= page && page < (data.length - 1)" + class="usa-pagination__item usa-pagination__page-no" + > + <a + class="usa-pagination__button" + :aria-label="`Page ${page + 1}`" + href="javascript:void(0);" + @click="page=page + 1" + > + {{ page + 1 }} + </a> + </li> + <li + v-if="page < data.length - 2" + class="usa-pagination__item usa-pagination__overflow" + > + <span>...</span> + </li> + <li + class="usa-pagination__item usa-pagination__page-no" + > + <a + :href="`${page === data.length ? '' : 'javascript:void(0);'}` || null" + :class="`usa-pagination__button ${page === data.length ? 'usa-current' : ''}`" + :aria-label="`Last page, page ${data.length}`" + @click="page=data.length" + > + {{ data.length }} + </a> + </li> + </ul> + </nav> + </table> +</template> + +<script> +import config from 'ui/config.js'; +import {ref, toRefs, computed} from 'vue'; + +/* + * @vue-prop {Array of Objects} dataSet - array of objects to be displayed + * @vue-prop {Array of Strings} headers - array of column headers + * @vue-prop {Number} rowsPerPage + * @vue-prop {String} title + * @vue-prop {Array of Strings} keyOrder - array of keys found in dataSet + */ +export default { + name: 'PaginatedTable', + props: { + dataSet: { + type: Array, + required: true + }, + headers: { + type: Array, + required: true + }, + rowsPerPage: { + type: Number, + default: 16 + }, + title: { + type: String, + required: true + }, + keyOrder: { + type: Array, + required: true + } + }, + + setup(props) { + const sortIconURL = `${config.STATIC_URL}img/sprite.svg#sort_arrow`; + const trackedData = toRefs(props).dataSet; + const trackedPagesPerRow = toRefs(props).rowsPerPage; + const trackedKeyOrder = toRefs(props).keyOrder; + const page = ref(1); + const sortOrder = ref('desc'); + const sortTarget = ref(trackedKeyOrder.value[0]); + + Array.prototype.objectSort = function(sortParameter) { + function compare(a, b) { + function isNumeric(num) { + return !isNaN(num); + } + + let left = isNumeric(a[sortParameter]) ? +a[sortParameter] : a[sortParameter]; + let right = isNumeric(b[sortParameter]) ? +b[sortParameter] : b[sortParameter]; + + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; + } + this.sort(compare); + }; + + const pagedArray = computed(() => { + let array = []; + let dataCopy = [...trackedData.value]; + switch (sortOrder.value) { + case 'desc': + dataCopy.objectSort(sortTarget.value); + dataCopy.reverse(); + break; + case 'asce': + dataCopy.objectSort(sortTarget.value); + break; + } + for (let i = 0; i < dataCopy.length; i += trackedPagesPerRow.value) { + array.push(dataCopy.slice(i, i + trackedPagesPerRow.value)); + } + return array; + }); + + return { + sortIconURL, + pagedArray, + data: pagedArray, + page, + myHeaders: toRefs(props).headers, + myTitle: toRefs(props).title, + myKeyOrder: toRefs(props).keyOrder, + sortOrder, + mySortTarget: sortTarget.value + }; + } +}; +</script> \ No newline at end of file diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.test.js index 4d74ce6dc592431b1963f576ef4d857652a9fa10..12565ea8c022f94619a618586ee07e4f77b83adf 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.test.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.test.js @@ -1,5 +1,4 @@ import {enableFetchMocks, disableFetchMocks} from 'jest-fetch-mock'; -import mockConsole from 'jest-mock-console'; import {bindActionCreators} from 'redux'; import ReduxConnectVue from 'redux-connect-vue'; import {createStructuredSelector} from 'reselect'; @@ -17,6 +16,8 @@ import {TEST_PRIMARY_IV_DATA, TEST_HYDROGRAPH_PARAMETERS} from '../mock-hydrogra import MethodPicker from './method-picker.vue'; import ParameterSelection from './parameter-selection.vue'; import ParameterSelectionExpansionControl from './parameter-selection-expansion-control.vue'; +import SecondaryParameterControls from './secondary-parameter-controls.vue'; + describe('monitoring-location/components/hydrograph/vue-components/parameter-selection', () => { utils.mediaQuery = jest.fn().mockReturnValue(true); @@ -47,6 +48,9 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel } }; + config.SENSOR_THINGS_ENDPOINT = 'https://fake-sensor-things-endpoint'; + config.IV_DATA_ENDPOINT = 'https://fake-iv-data-endpoint'; + const TEST_STATE = { hydrographData: { primaryIVData: TEST_PRIMARY_IV_DATA @@ -59,15 +63,12 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel } }; - let restoreConsole; beforeAll(() => { enableFetchMocks(); - restoreConsole = mockConsole(); }); afterAll(() => { disableFetchMocks(); - restoreConsole(); }); let retrieveHydrographDataSpy; @@ -148,7 +149,7 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel .toBe('1980-03-31 to 2020-04-01'); const expansionControls = wrapper.findAllComponents(ParameterSelectionExpansionControl); - expect(expansionControls).toHaveLength(4); + expect(expansionControls).toHaveLength(5); expect(expansionControls.find(control => control.props('parameterCode') === '62016')).toBeUndefined(); const expansionControlOne = periodOfRecordContainers[0].findComponent(ParameterSelectionExpansionControl); @@ -162,6 +163,8 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel }); it('Expects that clicking on a row selects that parameter and closes any open row and opens that parameter\'s row', async() => { + fetch.mockResponse(JSON.stringify({})); + const parameterRowContainers = wrapper.findAll('.parameter-row-container'); await parameterRowContainers[0].find('.parameter-row-info-container').trigger('click'); @@ -172,6 +175,8 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel }); it('Expects that clicking on a row updates the selected parameter code in the state and fetches new hydrograph data', async() => { + fetch.mockResponse(JSON.stringify({})); + const parameterRowContainers = wrapper.findAll('.parameter-row-container'); await parameterRowContainers[0].find('.parameter-row-info-container').trigger('click'); @@ -182,7 +187,7 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel it('Expects the expansion container to be only shown for the expanded row on', () => { const expansionContainers = wrapper.findAll('.expansion-container-row'); - expect(expansionContainers).toHaveLength(4); + expect(expansionContainers).toHaveLength(5); expect(expansionContainers[0].isVisible()).toBe(false); expect(expansionContainers[1].isVisible()).toBe(true); expect(expansionContainers[2].isVisible()).toBe(false); @@ -205,13 +210,6 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel expect(gwlevelsMethodPicker.attributes('sortedivmethods')).toBeDefined(); }); - it('Expects that clicking the expansion toggle in the selected row hides the expansion container', async() => { - const selectedExpansionToggle = wrapper.find('.selected').findComponent(ParameterSelectionExpansionControl); - await selectedExpansionToggle.vm.$emit('toggleExpansionRow', '72019', false); - - expect(wrapper.find('#expansion-row-72019').isVisible()).toBe(false); - }); - it('Expects that clicking on a hidden expansion toggle shows the expansion container but does not change the selection', async() => { const expansionToggles = wrapper.findAllComponents(ParameterSelectionExpansionControl); await expansionToggles[0].vm.$emit('toggleExpansionRow', '00060', true); @@ -223,10 +221,131 @@ describe('monitoring-location/components/hydrograph/vue-components/parameter-sel expect(parameterRows[1].classes()).toContain('selected'); }); + + it('Expects that clicking the expansion toggle in the selected row hides the expansion container', async() => { + const selectedExpansionToggle = wrapper.find('.selected').findComponent(ParameterSelectionExpansionControl); + await selectedExpansionToggle.vm.$emit('toggleExpansionRow', '72019', false); + + expect(wrapper.find('#expansion-row-72019').isVisible()).toBe(false); + }); + it('Expects that updated the selected method updates the store', async() => { const gwLevelMethodPicker = wrapper.find('#expansion-row-72019').findComponent(MethodPicker); await gwLevelMethodPicker.vm.$emit('selectMethod', '252055'); expect(store.getState().hydrographState.selectedIVMethodID).toBe('252055'); }); + + it('Expects that the second parameter selection component will show only on selected parameter row with the correct props', () => { + const parameterList = [ + { + 'actionValue': { + 'secondaryParameterCode': '00060' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00060', + 'label': 'Discharge, cubic feet per second' + }, + { + 'actionValue': { + 'secondaryParameterCode': '62610' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-62610', + 'label': 'Groundwater level above NGVD 1929, feet' + }, + { + 'actionValue': { + 'secondaryParameterCode': '00010' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00010', + 'label': 'Temperature, water, degrees Celsius' + }, + { + 'actionValue': { + 'secondaryParameterCode': '00010F' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00010F', + 'label': 'Temperature, water, degrees Fahrenheit' + } + ]; + + expect(wrapper.findAllComponents(SecondaryParameterControls)).toHaveLength(1); + const secondaryParameterControl = wrapper.findComponent(SecondaryParameterControls); + expect(secondaryParameterControl.props().parameterList).toStrictEqual(parameterList); + }); + + it('Expects that selecting a new parameter row will change the location of the second parameter selection component', async() => { + fetch.mockResponse(JSON.stringify({})); + + const rowContainers = wrapper.findAll('.parameter-row-container'); + expect(rowContainers).toHaveLength(5); + const rowForParameter00060 = rowContainers[0]; + const rowForParameter72019 = rowContainers[1]; + + expect(rowForParameter00060.classes()).not.toContain('selected'); + expect(rowForParameter00060.html()).not.toContain('secondary-parameter-controls-stub'); + expect(rowForParameter72019.classes()).toContain('selected'); + expect(rowForParameter72019.html()).toContain('secondary-parameter-controls-stub'); + + await rowForParameter00060.find('.parameter-row-info-container').trigger('click'); + expect(rowForParameter00060.classes()).toContain('selected'); + expect(rowForParameter00060.html()).toContain('secondary-parameter-controls-stub'); + expect(rowForParameter72019.classes()).not.toContain('selected'); + expect(rowForParameter72019.html()).not.toContain('secondary-parameter-controls-stub'); + }); + + it('Expects that when SecondaryParameterControls emit a radio button event the parameter list changes', async() => { + fetch.mockResponse(JSON.stringify({})); + + const parameterList = [ + { + 'actionValue': { + 'secondaryParameterCode': '72019' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-72019', + 'label': 'Depth to water level, feet below land surface' + }, + { + 'actionValue': { + 'secondaryParameterCode': '62610' + }, + 'checked': true, + 'id': 'second-parameter-radio-button-62610', + 'label': 'Groundwater level above NGVD 1929, feet' + }, + { + 'actionValue': { + 'secondaryParameterCode': '00010' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00010', + 'label': 'Temperature, water, degrees Celsius' + }, + { + 'actionValue': { + 'secondaryParameterCode': '00010F' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00010F', + 'label': 'Temperature, water, degrees Fahrenheit' + } + ]; + + const rowContainers = wrapper.findAll('.parameter-row-container'); + expect(rowContainers).toHaveLength(5); + const rowForParameter00060 = rowContainers[0]; + const rowForParameter72019 = rowContainers[1]; + expect(rowForParameter72019.classes()).toContain('selected'); + + expect(wrapper.findAllComponents(SecondaryParameterControls)).toHaveLength(1); + await wrapper.findComponent(SecondaryParameterControls).vm.$emit('checkedRadioButton', {'secondaryParameterCode': '62610'}); + await rowForParameter00060.find('.parameter-row-info-container').trigger('click'); + + const secondaryParameterControl = wrapper.findComponent(SecondaryParameterControls); + expect(secondaryParameterControl.props().parameterList).toStrictEqual(parameterList); + }); }); diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.vue index 85ea87cc3c9b0f64381e84e07db37b4587bc9bfb..265a6ce829ea443d0492efc16000d12d31183e93 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.vue +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/parameter-selection.vue @@ -22,7 +22,6 @@ {{ parameter.periodOfRecord.begin_date }} to {{ parameter.periodOfRecord.end_date }} </div> <ParameterSelectionExpansionControl - v-if="parameter.waterAlert.hasWaterAlert" :parameter-code="parameter.parameterCode" :is-expanded="parameter.parameterCode === expandedParameterCode" :id-for-expansion-row="`expansion-row-${parameter.parameterCode}`" @@ -47,7 +46,6 @@ </div> </div> <div - v-if="parameter.waterAlert.hasWaterAlert || (sortedIvMethods && parameter.parameterCode === sortedIvMethods.parameterCode && sortedIvMethods.length > 1)" v-show="parameter.parameterCode === expandedParameterCode" :id="`expansion-row-${parameter.parameterCode}`" class="expansion-container-row" @@ -69,6 +67,15 @@ :sorted-iv-methods="sortedIvMethods" @selectMethod="updateSelectedMethod" /> + + <SecondaryParameterControls + v-if="parameter.parameterCode === expandedParameterCode" + :parameter-list="secondaryParameterButtonList" + :is-show-second-parameter-options-checked="isShowSecondParameterOptionsChecked" + @checkedCheckBox="setCheckBoxStatus" + @checkedRadioButton="setRadioButtonStatus" + @disableEnableMedianAndCompare="disableEnableMedianAndCompare" + /> </div> </div> </div> @@ -78,7 +85,7 @@ <script> import {useActions, useState} from 'redux-connect-vue'; import {createSelector} from 'reselect'; -import {inject, ref} from 'vue'; +import {inject, ref, computed} from 'vue'; import config from 'ui/config'; @@ -94,12 +101,14 @@ import {showDataIndicators} from '../data-indicator'; import MethodPicker from './method-picker.vue'; import ParameterSelectionExpansionControl from './parameter-selection-expansion-control.vue'; +import SecondaryParameterControls from './secondary-parameter-controls.vue'; export default { name: 'ParameterSelection', components: { MethodPicker, - ParameterSelectionExpansionControl + ParameterSelectionExpansionControl, + SecondaryParameterControls }, setup() { const reduxStore = inject('store'); @@ -109,19 +118,20 @@ export default { const expandedParameterCode = ref(getSelectedParameterCode(reduxStore.getState())); const getAvailableParameterData = createSelector( - getAvailableParameters, - (parameters) => { - return parameters.map((parameter) => { - const parameterForWaterAlertUrl = parameter.parameterCode.includes(config.CALCULATED_TEMPERATURE_VARIABLE_CODE) ? - parameter.parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE) : parameter.parameterCode; - return { - ...parameter, - waterAlertUrl: parameter.waterAlert.hasWaterAlert ? - `${config.WATERALERT_SUBSCRIPTION}/?site_no=${siteno}&parm=${parameterForWaterAlertUrl}` : '' - }; - }); - } + getAvailableParameters, + (parameters) => { + return parameters.map((parameter) => { + const parameterForWaterAlertUrl = parameter.parameterCode.includes(config.CALCULATED_TEMPERATURE_VARIABLE_CODE) ? + parameter.parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE) : parameter.parameterCode; + return { + ...parameter, + waterAlertUrl: parameter.waterAlert.hasWaterAlert ? + `${config.WATERALERT_SUBSCRIPTION}/?site_no=${siteno}&parm=${parameterForWaterAlertUrl}` : '' + }; + }); + } ); + const state = useState({ parameters: getAvailableParameterData, sortedIvMethods: getSortedIVMethods, @@ -134,23 +144,71 @@ export default { retrieveHydrographData }); + // Second parameter selection code + const selectedSecondaryParameter = ref(''); + const showSecondParameterList = ref(false); + + const isShowSecondParameterOptionsChecked = ref(false); + const setCheckBoxStatus = function() { + isShowSecondParameterOptionsChecked.value = !isShowSecondParameterOptionsChecked.value; + }; + + let parameterCodeOfSecondaryRadioButton = ''; + const setRadioButtonStatus = function(buttonData) { + parameterCodeOfSecondaryRadioButton = buttonData.secondaryParameterCode; + }; + + const secondaryParameterButtonList = computed(() => { + const radioButtonList = []; + state.parameters.value.forEach((parameter) => { + if (parameter.parameterCode !== expandedParameterCode.value) { + const buttonDetail = { + id: `second-parameter-radio-button-${parameter.parameterCode}`, + label: parameter.description, + checked: parameterCodeOfSecondaryRadioButton === parameter.parameterCode, + actionValue: { + secondaryParameterCode: parameter.parameterCode + } + }; + radioButtonList.push(buttonDetail); + } + }); + + return radioButtonList; + }); + + // TODO: when graph-controls and parameter-selection are part of the same app, use Vue to control elements + const compareToLastYearCheckBox = document.querySelector('#iv-compare-timeseries-checkbox'); + const medianCheckBox = document.querySelector('#iv-median-timeseries-checkbox'); + const disableEnableMedianAndCompare = function(isEnabled) { + compareToLastYearCheckBox.disabled = isEnabled; + medianCheckBox.disabled = isEnabled; + }; + + // Primary parameter selection code function toggleExpansionRow(parameterCode, expandRow) { - expandedParameterCode.value = expandRow ? parameterCode : ''; + expandedParameterCode.value = expandRow ? parameterCode : ''; } function selectParameter(parameterCode) { + if (parameterCodeOfSecondaryRadioButton === expandedParameterCode.value) { + isShowSecondParameterOptionsChecked.value = false; + // TODO: Hide second parameter visibility -- perhaps as part of WDFN-732 + console.log(`Primary button clicked, ${parameterCode}, is same as secondary selection. Remove second parameter from graph`); + } + actions.setSelectedParameterCode(parameterCode); expandedParameterCode.value = parameterCode; showDataIndicators(true, reduxStore); actions.retrieveHydrographData(siteno, agencyCode, getInputsForRetrieval(reduxStore.getState()), true) - .then(() => { - const sortedMethods = getSortedIVMethods(reduxStore.getState()); + .then(() => { + const sortedMethods = getSortedIVMethods(reduxStore.getState()); if (sortedMethods && sortedMethods.methods.length) { actions.setSelectedIVMethodID(sortedMethods.methods[0].methodID); } showDataIndicators(false, reduxStore); - }); + }); } function updateSelectedMethod(methodId) { @@ -160,7 +218,14 @@ export default { return { ...state, expandedParameterCode, + disableEnableMedianAndCompare, + isShowSecondParameterOptionsChecked, + secondaryParameterButtonList, selectParameter, + selectedSecondaryParameter, + setCheckBoxStatus, + setRadioButtonStatus, + showSecondParameterList, toggleExpansionRow, updateSelectedMethod }; diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.test.js new file mode 100644 index 0000000000000000000000000000000000000000..3ceb186a8e1fb7c1a91b0c571b8e7f5848afeecb --- /dev/null +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.test.js @@ -0,0 +1,148 @@ +import {shallowMount} from '@vue/test-utils'; + +import USWDSCheckbox from 'ui/uswds-components/checkbox.vue'; +import USWDSRadioButtonSet from 'ui/uswds-components/radio-button-set.vue'; + +import SecondaryParameterControls from './secondary-parameter-controls.vue'; +import ReduxConnectVue from 'redux-connect-vue'; +import {bindActionCreators} from 'redux'; +import {createStructuredSelector} from 'reselect'; +import {TEST_HYDROGRAPH_PARAMETERS, TEST_PRIMARY_IV_DATA} from '../mock-hydrograph-state'; +import {configureStore} from 'ml/store'; + + + +describe('monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls', () => { + const parameterList = [ + { + 'actionValue': { + 'secondaryParameterCode': '00060' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-00060', + 'label': 'Discharge, cubic feet per second' + }, + { + 'actionValue': { + 'secondaryParameterCode': '62610' + }, + 'checked': false, + 'id': 'second-parameter-radio-button-62610', + 'label': 'Groundwater level above NGVD 1929, feet' + } + ]; + + let wrapper; + let store; + + const TEST_STATE = { + hydrographData: { + primaryIVData: TEST_PRIMARY_IV_DATA + }, + hydrographParameters: TEST_HYDROGRAPH_PARAMETERS, + hydrographState: { + selectedTimeSpan: 'P7D', + selectedParameterCode: '72019', + selectedIVMethodID: '90649' + } + }; + + describe('tests with check box checked', () => { + beforeEach(() => { + store = configureStore(TEST_STATE); + wrapper = shallowMount(SecondaryParameterControls, { + global: { + plugins: [ + [ReduxConnectVue, { + store, + mapDispatchToPropsFactory: (actionCreators) => (dispatch) => bindActionCreators(actionCreators, dispatch), + mapStateToPropsFactory: createStructuredSelector + }] + ], + provide: { + store: store + } + }, + props: { + parameterList: parameterList, + isShowSecondParameterOptionsChecked: true + } + }); + }); + + it('expects radio buttons will show if checkbox checked', () => { + expect(wrapper.findAllComponents(USWDSCheckbox)).toHaveLength(1); + expect(wrapper.findAllComponents(USWDSRadioButtonSet)).toHaveLength(1); + }); + + it('expects radio buttons will have correct props', () => { + expect(wrapper.findAllComponents(USWDSCheckbox)).toHaveLength(1); + expect(wrapper.findAllComponents(USWDSRadioButtonSet)).toHaveLength(1); + + const radioButtonSet = wrapper.findComponent(USWDSRadioButtonSet); + expect(radioButtonSet.props('buttonList')).toBe(parameterList); + expect(radioButtonSet.props('buttonSetName')).toBe('second-parameter-radio-button-set'); + expect(radioButtonSet.props('legendText')).toBe('Available secondary data types'); + }); + + it('expects unchecking check box will emit correct events and payload', async() => { + const checkboxComponent = wrapper.findComponent(USWDSCheckbox); + await checkboxComponent.vm.$emit('toggleCheckbox'); + + expect(wrapper.emitted().disableEnableMedianAndCompare[0][0]).toBe( false); + expect(wrapper.emitted().checkedRadioButton[0][0]).toStrictEqual({ + secondaryParameterCode: '' + }); + expect(wrapper.emitted().checkedCheckBox[0][0]).not.toBeTruthy(); + }); + + it('expects clicking a radio button will emit the correct event', async() => { + const radioButtonSetComponent = wrapper.findComponent(USWDSRadioButtonSet); + + await radioButtonSetComponent.vm.$emit('updateValue', { + 'secondaryParameterCode': '62610' + }); + + expect(wrapper.emitted().disableEnableMedianAndCompare[0][0]).toBe( true); + expect(wrapper.emitted().checkedRadioButton[0][0]).toStrictEqual( {'secondaryParameterCode': '62610'}); + }); + }); + + describe('tests with check box not checked', () => { + beforeEach(() => { + store = configureStore(TEST_STATE); + wrapper = shallowMount(SecondaryParameterControls, { + global: { + plugins: [ + [ReduxConnectVue, { + store, + mapDispatchToPropsFactory: (actionCreators) => (dispatch) => bindActionCreators(actionCreators, dispatch), + mapStateToPropsFactory: createStructuredSelector + }] + ], + provide: { + store: store + } + }, + props: { + parameterList: parameterList, + isShowSecondParameterOptionsChecked: false + } + }); + }); + + it('Expects radio buttons will not show if checkbox not checked', () => { + expect(wrapper.findAllComponents(USWDSCheckbox)).toHaveLength(1); + expect(wrapper.findAllComponents(USWDSRadioButtonSet)).toHaveLength(0); + }); + + it('expects checking check box will emit correct events and payload', async() => { + const checkboxComponent = wrapper.findComponent(USWDSCheckbox); + + await checkboxComponent.vm.$emit('toggleCheckbox'); + expect(wrapper.emitted('disableEnableMedianAndCompare')).not.toBeTruthy(); + expect(wrapper.emitted('checkedRadioButton')).not.toBeTruthy(); + expect(wrapper.emitted().checkedCheckBox[0][0]).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.vue new file mode 100644 index 0000000000000000000000000000000000000000..a45a637fa644c1e4a763512c8c341da9a947e909 --- /dev/null +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/secondary-parameter-controls.vue @@ -0,0 +1,93 @@ +<template> + <div> + <USWDSCheckbox + id="checkbox-show-second-parameter" + label="Select data to graph on second x-axis" + name="second-parameter-checkbox" + value="graphSecondParameter" + :is-checked="isShowSecondParameterOptionsChecked" + @toggleCheckbox="showSecondParameterOptions" + /> + + <USWDSRadioButtonSet + v-if="isShowSecondParameterOptionsChecked" + button-set-name="second-parameter-radio-button-set" + legend-text="Available secondary data types" + :button-list="parameterList" + @updateValue="graphSecondParameter" + /> + </div> +</template> + +<script> +import {ref} from 'vue'; + +import {useActions} from 'redux-connect-vue'; +import {setCompareDataVisibility, setMedianDataVisibility} from 'ml/store/hydrograph-state'; + +import USWDSCheckbox from 'ui/uswds-components/checkbox.vue'; +import USWDSRadioButtonSet from 'ui/uswds-components/radio-button-set.vue'; + + +export default { + name: 'SecondaryParameterControls', + components: { + USWDSCheckbox, + USWDSRadioButtonSet + }, + props: { + parameterList: { + type: Object, + required: true + }, + isShowSecondParameterOptionsChecked: { + type: Boolean, + required: true + } + }, + setup(props, {emit}) { + const actions = useActions({ + setCompareDataVisibility, + setMedianDataVisibility + }); + + const showSecondParameterList = ref(false); + + const showSecondParameterOptions = function() { + showSecondParameterList.value = !showSecondParameterList.value; + const isBoxChecked = !props.isShowSecondParameterOptionsChecked; + + if (!isBoxChecked) { + emit('disableEnableMedianAndCompare', false); + emit('checkedRadioButton', { + secondaryParameterCode: '' + }); + // TODO: Hide second parameter visibility -- perhaps as part of WDFN-732 + console.log('check box unchecked: clear any second parameter data from graph '); + } + + emit('checkedCheckBox', isBoxChecked); + }; + + const disableCompareAndMedianButtons = function() { + actions.setCompareDataVisibility(false); + actions.setMedianDataVisibility(false); + emit('disableEnableMedianAndCompare', true); + }; + + const graphSecondParameter = function(buttonData) { + disableCompareAndMedianButtons(); + emit('checkedRadioButton', buttonData); + // TODO: Add data to state -- part of WDFN-735 + // TODO: Graph data on hydrograph -- part of WDFN-735 + console.log(`Radio button, ${buttonData.secondaryParameterCode} clicked: fetch data and then run graphing code using secondary parameter ${buttonData.secondaryParameterCode}`); + }; + + return { + showSecondParameterList, + showSecondParameterOptions, + graphSecondParameter + }; + } +}; +</script> \ No newline at end of file diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.test.js index b35e4b402c54cb2b6ae94f347709254200c55a4e..c7730945bb9e4397360fb69a44951db2fcebd185 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.test.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.test.js @@ -1,5 +1,3 @@ -import {enableFetchMocks, disableFetchMocks} from 'jest-fetch-mock'; -import mockConsole from 'jest-mock-console'; import {bindActionCreators} from 'redux'; import ReduxConnectVue from 'redux-connect-vue'; import {createStructuredSelector} from 'reselect'; @@ -7,25 +5,14 @@ import {mount} from '@vue/test-utils'; import {configureStore} from 'ml/store'; -import StatisticsTableApp from './statistics-table.vue'; +import StatisticsTable from './statistics-table.vue'; import {TEST_STATS_DATA} from '../mock-hydrograph-state'; describe('monitoring-location/components/hydrograph/statistics', () => { - describe('Cases with data available', () => { - let restoreConsole; - var store; - var wrapper; - - beforeAll(() => { - enableFetchMocks(); - restoreConsole = mockConsole(); - }); - - afterAll(() => { - disableFetchMocks(); - restoreConsole(); - }); + describe('Cases with multiple methods available', () => { + let store; + let wrapper; beforeEach(() => { jest.useFakeTimers('modern'); @@ -54,7 +41,7 @@ describe('monitoring-location/components/hydrograph/statistics', () => { } }); - wrapper = mount(StatisticsTableApp, { + wrapper = mount(StatisticsTable, { global: { plugins: [ [ReduxConnectVue, { @@ -84,7 +71,89 @@ describe('monitoring-location/components/hydrograph/statistics', () => { }); it('Expects the table to have headers', () => { - let tableHeaders = wrapper.find('thead').find('tr').findAll('tr > th'); + const tableHeaders = wrapper.find('thead').find('tr').findAll('tr > th'); + expect(tableHeaders).toHaveLength(6); + expect(tableHeaders[0].text()).toBe('Lowest Value (2006)'); + expect(tableHeaders[1].text()).toBe('25th Percentile'); + expect(tableHeaders[2].text()).toBe('Median'); + expect(tableHeaders[3].text()).toBe('75th Percentile'); + expect(tableHeaders[4].text()).toBe('Mean'); + expect(tableHeaders[5].text()).toBe('Highest Value (2020)'); + }); + + it('Expects the table to have data in it', () => { + const tableHeaders = wrapper.find('tbody').find('tr').findAll('tr > th'); + expect(tableHeaders).toHaveLength(6); + expect(tableHeaders[0].text()).toBe('550.5'); + expect(tableHeaders[1].text()).toBe('1000'); + expect(tableHeaders[2].text()).toBe('160'); + expect(tableHeaders[3].text()).toBe('2240'); + expect(tableHeaders[4].text()).toBe('1530'); + expect(tableHeaders[5].text()).toBe('2730'); + }); + }); + + describe('Cases with one method available', () => { + let store; + let wrapper; + + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(2020, 0, 1)); + + store = configureStore({ + hydrographData: { + statisticsData: {'153885' : TEST_STATS_DATA['153885']}, + primaryIVData: { + parameter: { + parameterCode: '153885', + name: 'Test Name' + }, + values: {} + } + }, + hydrographParameters: { + '153885': { + parameterCode: '153885', + name: 'Test Name', + latestValue: '25.9' + } + }, + hydrographState: { + selectedParameterCode: '153885' + } + }); + + wrapper = mount(StatisticsTable, { + global: { + plugins: [ + [ReduxConnectVue, { + store, + mapDispatchToPropsFactory: (actionCreators) => (dispatch) => bindActionCreators(actionCreators, dispatch), + mapStateToPropsFactory: createStructuredSelector + }] + ] + } + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('Displays the accordion', () => { + expect(wrapper.find('.stats-accordion').isVisible()).toBe(true); + }); + + it('Creates multiple tables', () => { + const captions = wrapper.findAll('caption'); + expect(wrapper.findAll('#daily-stats-tables div')).toHaveLength(1); + expect(captions).toHaveLength(1); + expect(captions[0].text()).toContain('Test Name (Method1)'); + }); + + it('Expects the table to have headers', () => { + const tableHeaders = wrapper.find('thead').find('tr').findAll('tr > th'); expect(tableHeaders).toHaveLength(7); expect(tableHeaders[0].text()).toBe('Latest Value'); expect(tableHeaders[1].text()).toBe('Lowest Value (2006)'); @@ -96,33 +165,22 @@ describe('monitoring-location/components/hydrograph/statistics', () => { }); it('Expects the table to have data in it', () => { - let tableHeaders = wrapper.find('tbody').find('tr').findAll('tr > th'); + const tableHeaders = wrapper.find('tbody').find('tr').findAll('tr > th'); expect(tableHeaders).toHaveLength(7); expect(tableHeaders[0].text()).toBe('25.9'); - expect(tableHeaders[1].text()).toBe('550.5'); - expect(tableHeaders[2].text()).toBe('1000'); - expect(tableHeaders[3].text()).toBe('160'); - expect(tableHeaders[4].text()).toBe('2240'); - expect(tableHeaders[5].text()).toBe('1530'); - expect(tableHeaders[6].text()).toBe('2730'); + expect(tableHeaders[1].text()).toBe('55.5'); + expect(tableHeaders[2].text()).toBe('100'); + expect(tableHeaders[3].text()).toBe('16'); + expect(tableHeaders[4].text()).toBe('224'); + expect(tableHeaders[5].text()).toBe('153'); + expect(tableHeaders[6].text()).toBe('273'); }); }); describe('Cases with no stats data available', () => { - let restoreConsole; - var store; - var wrapper; - - beforeAll(() => { - enableFetchMocks(); - restoreConsole = mockConsole(); - }); - - afterAll(() => { - disableFetchMocks(); - restoreConsole(); - }); + let store; + let wrapper; beforeEach(() => { jest.useFakeTimers('modern'); @@ -142,7 +200,7 @@ describe('monitoring-location/components/hydrograph/statistics', () => { } }); - wrapper = mount(StatisticsTableApp, { + wrapper = mount(StatisticsTable, { global: { plugins: [ [ReduxConnectVue, { @@ -165,19 +223,8 @@ describe('monitoring-location/components/hydrograph/statistics', () => { }); describe('Cases with no latest value available', () => { - let restoreConsole; - var store; - var wrapper; - - beforeAll(() => { - enableFetchMocks(); - restoreConsole = mockConsole(); - }); - - afterAll(() => { - disableFetchMocks(); - restoreConsole(); - }); + let store; + let wrapper; beforeEach(() => { jest.useFakeTimers('modern'); @@ -196,7 +243,7 @@ describe('monitoring-location/components/hydrograph/statistics', () => { } }); - wrapper = mount(StatisticsTableApp, { + wrapper = mount(StatisticsTable, { global: { plugins: [ [ReduxConnectVue, { diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.vue index fc8d6706a4b8f1f2b25218eba42983da9558f6b9..6f5d1fadd6c1b7606592f5a0db255856df270fe1 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.vue +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/statistics-table.vue @@ -73,6 +73,7 @@ import {getDailyStatistics} from 'ml/components/hydrograph/selectors/statistics' import {latestSelectedParameterValue} from 'ml/selectors/hydrograph-parameters-selector'; export default { + name: 'StatisticsTable', setup() { const COLUMN_HEADINGS = ['Latest Value', 'Lowest Value', '25th Percentile', 'Median', '75th Percentile', 'Mean', 'Highest Value']; const DATA_HEADINGS = ['min_va', 'p25_va', 'p50_va', 'p75_va', 'mean_va', 'max_va']; @@ -86,14 +87,16 @@ export default { return []; } + const isOnlyOneMethod = dailyStatsObjectArray.length === 1; + return dailyStatsObjectArray.map(dailyStatsObject => { let columnHeadings = [...COLUMN_HEADINGS]; let dailyStatsArray = DATA_HEADINGS.map((key) => dailyStatsObject[key]); columnHeadings[1] = `${COLUMN_HEADINGS[1]} (${dailyStatsObject['min_va_yr']})`; columnHeadings[6] = `${COLUMN_HEADINGS[6]} (${dailyStatsObject['max_va_yr']})`; return { - tableData: [latestValue, ...dailyStatsArray], - columnHeadings: columnHeadings, + tableData: isOnlyOneMethod ? [latestValue, ...dailyStatsArray] : dailyStatsArray, + columnHeadings: isOnlyOneMethod ? columnHeadings : columnHeadings.slice(1), yearCount: dailyStatsObject['count_nu'], location_description: dailyStatsObject['loc_web_ds'] }; diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.test.js b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.test.js index 8c86b215dc749de59fb1447a2f258dd0865a5cba..6fe1374a5c593f2a063f84dcae028a440a11939f 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.test.js +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.test.js @@ -122,6 +122,54 @@ describe('monitoring-location/components/hydrograph/components/graph-controls', expect(labels[2].text()).toBe('1 year'); }); + it('Expects to render the shortcut radio buttons and check the 7 day radio button with a calculated temperature parameter code', () => { + store = configureStore({ + hydrographState: { + showCompareIVData: false, + selectedTimeSpan: 'P7D', + showMedianData: false, + selectedParameterCode: '00010F' + } + }); + + wrapper = mount(TimeSpanShortcuts, { + global: { + plugins: [ + [ReduxConnectVue, { + store, + mapDispatchToPropsFactory: (actionCreators) => (dispatch) => bindActionCreators(actionCreators, dispatch), + mapStateToPropsFactory: createStructuredSelector + }] + ], + provide: { + store: store, + siteno: '11112222', + agencyCd: 'USGS' + } + } + }); + + const radio7Day = wrapper.find('#P7D-input'); + const radio30Day = wrapper.find('#P30D-input'); + const radio1Year = wrapper.find('#P365D-input'); + const labels = wrapper.findAll('.usa-radio__label'); + + expect(wrapper.findAll('.iv-button-container')).toHaveLength(1); + expect(wrapper.findAll('.gw-button-container')).toHaveLength(0); + + expect(radio7Day.attributes('value')).toBe('P7D'); + expect(radio7Day.element.checked).toBe(true); + expect(labels[0].text()).toBe('7 days'); + + expect(radio30Day.attributes('value')).toBe('P30D'); + expect(radio30Day.element.checked).toBe(false); + expect(labels[1].text()).toBe('30 days'); + + expect(radio1Year.attributes('value')).toBe('P365D'); + expect(radio1Year.element.checked).toBe(false); + expect(labels[2].text()).toBe('1 year'); + }); + it('Expects that if the selectedTimeSpan is changed to a days before that is not a shortcut, they are all unset', async() => { store.dispatch(setSelectedTimeSpan('P45D')); await wrapper.vm.$nextTick(); diff --git a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.vue b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.vue index 2fb202f9b20a0c22df93d6e092be7df7ecaed3cd..af2babd7aac3ac38615640a8c73c44165bfd0fce 100644 --- a/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.vue +++ b/assets/src/scripts/monitoring-location/components/hydrograph/vue-components/time-span-shortcuts.vue @@ -76,14 +76,14 @@ export default { const hasIVData = createSelector( getSelectedParameterCode, (parameterCode) => { - return config.ivPeriodOfRecord && parameterCode in config.ivPeriodOfRecord; + return config.ivPeriodOfRecord && parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE, '') in config.ivPeriodOfRecord; } ); const hasGWData = createSelector( getSelectedParameterCode, (parameterCode) => { - return config.gwPeriodOfRecord && parameterCode in config.gwPeriodOfRecord; + return config.gwPeriodOfRecord && parameterCode.replace(config.CALCULATED_TEMPERATURE_VARIABLE_CODE, '') in config.gwPeriodOfRecord; } ); diff --git a/assets/src/scripts/monitoring-location/store/hydrograph-data.js b/assets/src/scripts/monitoring-location/store/hydrograph-data.js index 4990f577500c080b6d9a4495887ca1f36b811817..30a6be974d33b91aa0d7afb7be61efb0c89fe711 100644 --- a/assets/src/scripts/monitoring-location/store/hydrograph-data.js +++ b/assets/src/scripts/monitoring-location/store/hydrograph-data.js @@ -130,7 +130,7 @@ const retrieveIVData = function(siteno, dataKind, {parameterCode, period, startT parameter = getConvertedTemperatureParameter(parameter); } - // The 'code' for no data value come from the web service as a number but the point values come + // The 'code' for no data value comes from the web service as a number, but the point values come // as strings, so convert it to a string like the point values and let JavaScript compare them. const noDataValue = tsData.variable.noDataValue.toString(); @@ -254,7 +254,7 @@ export const retrieveStatistics = function(siteno, parameterCode) { /** * Removes the unneeded data from Sensor Things and returns only the essential information for thresholds * @param {Object} properties - Complex object containing threshold data along with unneeded data returned from Sensor Things - * @return {Object} A well organised object containg threshold information + * @return {Object} A well organised object containing threshold information */ const cleanThresholdData = function(properties) { const THRESHOLD_CODES = ['Operational limit - low-Public', 'Operational limit - high-Public']; diff --git a/assets/src/scripts/uswds-components/radio-button-set.test.js b/assets/src/scripts/uswds-components/radio-button-set.test.js new file mode 100644 index 0000000000000000000000000000000000000000..fbdebc46cc726903c9f232efbcccc2da998d43d5 --- /dev/null +++ b/assets/src/scripts/uswds-components/radio-button-set.test.js @@ -0,0 +1,86 @@ +import {shallowMount} from '@vue/test-utils'; + +import RadioButtonSet from './radio-button-set.vue'; + +describe('components/radio-button-set', () => { + it('expects the buttons will match the props passed in', () => { + const buttonList = [ + { + id: 'button-one', + label: 'Button one - choose me!', + checked: true, + actionValue: 'button one clicked' + }, + { + id: 'button-two', + label: 'Button two - no, choose me!', + checked: false, + actionValue: 'button two clicked' + } + ]; + + const wrapper = shallowMount(RadioButtonSet, { + props: { + 'buttonSetName': 'test-set', + 'buttonList': buttonList, + 'legendText': 'A set of radio buttons' + } + }); + + expect(wrapper.findAll('.usa-radio')).toHaveLength(2); + const buttonOne = wrapper.findAll('.usa-radio').at(0); + const buttonTwo = wrapper.findAll('.usa-radio').at(1); + expect(buttonOne.attributes('checked')).valueOf(true); + expect(buttonTwo.attributes('checked')).valueOf(false); + + expect(wrapper.findAll('input')).toHaveLength(2); + const buttonInputOne = wrapper.findAll('input')[0]; + const buttonInputTwo = wrapper.findAll('input')[1]; + expect(buttonInputOne.attributes('id')).valueOf('button-one'); + expect(buttonInputTwo.attributes('id')).valueOf('button-two'); + expect(buttonInputOne.attributes('name')).valueOf('test-set'); + expect(buttonInputTwo.attributes('name')).valueOf('test-set'); + + expect(wrapper.findAll('legend')).toHaveLength(1); + expect(wrapper.findAll('legend')[0].text()).toBe('A set of radio buttons'); + }); + + it('expects the buttons will emit the correct events', async() => { + const buttonList = [ + { + id: 'button-one', + label: 'Button one - choose me!', + checked: true, + actionValue: 'button one clicked' + }, + { + id: 'button-two', + label: 'Button two - no, choose me!', + checked: false, + actionValue: 'button two clicked' + } + ]; + + const wrapper = shallowMount(RadioButtonSet, { + props: { + 'buttonSetName': 'test-set', + 'buttonList': buttonList, + 'legendText': 'A set of radio buttons' + } + }); + + expect(wrapper.findAll('input')).toHaveLength(2); + + const buttonOne = wrapper.findAll('input')[0]; + const buttonTwo = wrapper.findAll('input')[1]; + + await buttonOne.trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted().updateValue).toBeTruthy(); + expect(wrapper.emitted().updateValue).toEqual([['button one clicked']]); + + await buttonTwo.trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted().updateValue).toEqual([['button one clicked'], ['button two clicked']]); + }); +}); diff --git a/assets/src/scripts/uswds-components/radio-button-set.vue b/assets/src/scripts/uswds-components/radio-button-set.vue new file mode 100644 index 0000000000000000000000000000000000000000..78867b1e75fd613791abda61a3321f47190d14c3 --- /dev/null +++ b/assets/src/scripts/uswds-components/radio-button-set.vue @@ -0,0 +1,72 @@ +<template> + <div class="radio-button-set"> + <fieldset class="usa-fieldset"> + <legend class="usa-legend"> + {{ legendText }} + </legend> + <div + v-for="item in buttonList" + :key="item.id" + class="usa-radio" + > + <input + :id="item.id" + class="usa-radio__input" + type="radio" + :name="buttonSetName" + :checked="item.checked" + @click="updateValue(item.actionValue)" + > + <label + class="usa-radio__label" + :for="item.id" + > + {{ item.label }} + </label> + </div> + </fieldset> + </div> +</template> + +<script> +/* +* Renders a generic radio button set +* @vue-prop {String} buttonSetName - must be unique for each set +* @vue-prop {Array} buttonList - an array of objects with details about each button in the set +* each button object must contain the following values: +* { +* id: {String} unique to each button, +* label: {String} the label for the button, +* checked: {Boolean} if the button is checked at start (only use true on one button), +* actionValue: {String} Value that the clicked button will return to the parent component +* } +* @vue-prop {String} legendText - The text which will be put into the legend text +* @vue-event updateValue - passes the actionValue of the radio button clicked +*/ +export default { + name: 'RadioButtonSet', + props: { + buttonSetName: { + type: String, + required: true + }, + buttonList: { + type: Array, + required: true + }, + legendText: { + type: String, + default: '' + } + }, + setup(props, {emit}) { + const updateValue = function(value) { + emit('updateValue', value); + }; + + return { + updateValue + }; + } +}; +</script> diff --git a/assets/src/styles/monitoring-location.scss b/assets/src/styles/monitoring-location.scss index c45594af0b4c1d9b821e7db5b4ba04beccb8aa55..c11a8fcd9e12499f79d947a8bfae55f30e943384 100644 --- a/assets/src/styles/monitoring-location.scss +++ b/assets/src/styles/monitoring-location.scss @@ -26,6 +26,7 @@ @forward 'usa-select'; @forward 'usa-tag'; @forward 'usa-tooltip'; +@forward 'usa-pagination'; @use './components/dv-hydrograph'; @use './components/hydrograph/app'; diff --git a/assets/src/styles/partials/_parameter-list.scss b/assets/src/styles/partials/_parameter-list.scss index 9731e34e5ecee5e00bd319356cbe61c9b7f31ae0..25a740c3ad3e54242341331c4df2a22326af7574 100644 --- a/assets/src/styles/partials/_parameter-list.scss +++ b/assets/src/styles/partials/_parameter-list.scss @@ -29,6 +29,12 @@ $row-border-color: 'black'; } } + .parameter-row-container.selected { + .usa-checkbox { + background-color: variables.$selected; + } + } + .parameter-row-container { cursor: pointer; @include uswds.u-border-bottom(1px, $row-border-color); diff --git a/wdfn-server/waterdata/services/sifta.py b/wdfn-server/waterdata/services/sifta.py index 068c88d068dfeeaabcdd4c1544111159d30d831c..7830c71206e94116b089fc1adb005a0da561ea93 100644 --- a/wdfn-server/waterdata/services/sifta.py +++ b/wdfn-server/waterdata/services/sifta.py @@ -35,5 +35,5 @@ async def get_cooperators(session, site_no): else: return False, resp_json.get('Customers', []) except (ServerDisconnectedError, ClientConnectorError) as err: - app.logger.error(f'Sifta server to ${site_no} failed with error: ${repr(err)}') + app.logger.error(f'Sifta server to {site_no} failed with error: {repr(err)}') return True, []