From 4407aeac20fa7d6ca317b00ec4508ca3836857a8 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 15 May 2019 10:06:14 -0400 Subject: [PATCH] Add namespacing for query params (#205) * use qs utils to namespace query params * refactor Lookup and SelectResource Steps to use PaginatedDataList * preserve query params when adding new ones * require namespace for QS Configs --- __tests__/components/Lookup.test.jsx | 108 +- .../PaginatedDataList.test.jsx | 18 +- .../components/SelectResourceStep.test.jsx | 100 +- __tests__/enzymeHelpers.jsx | 43 +- __tests__/enzymeHelpers.test.jsx | 15 + .../Organization/OrganizationTeams.test.jsx | 69 +- .../OrganizationNotifications.test.jsx.snap | 4153 +++++++++-------- .../screens/OrganizationsList.test.jsx | 18 - __tests__/util/qs.test.js | 202 +- src/components/AddRole/AddResourceRole.jsx | 6 +- src/components/AddRole/SelectResourceStep.jsx | 207 +- src/components/Lookup/Lookup.jsx | 136 +- .../PaginatedDataList/PaginatedDataList.jsx | 49 +- .../Organization/OrganizationAccess.jsx | 22 +- .../OrganizationNotifications.jsx | 22 +- .../Organization/OrganizationTeams.jsx | 22 +- .../screens/OrganizationsList.jsx | 37 +- src/types.js | 6 + src/util/qs.js | 71 + 19 files changed, 2656 insertions(+), 2648 deletions(-) diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx index 802b67082c..b39dda3431 100644 --- a/__tests__/components/Lookup.test.jsx +++ b/__tests__/components/Lookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../enzymeHelpers'; import Lookup from '../../src/components/Lookup'; import { _Lookup } from '../../src/components/Lookup/Lookup'; @@ -10,8 +11,8 @@ const mockColumns = [ describe('', () => { test('initially renders succesfully', () => { mountWithContexts( - <_Lookup - lookup_header="Foo Bar" + { }} @@ -25,8 +26,8 @@ describe('', () => { test('API response is formatted properly', (done) => { const wrapper = mountWithContexts( - <_Lookup - lookup_header="Foo Bar" + { }} @@ -47,9 +48,9 @@ describe('', () => { const spy = jest.spyOn(_Lookup.prototype, 'handleModalToggle'); const mockSelected = [{ name: 'foo', id: 1 }]; const wrapper = mountWithContexts( - <_Lookup + { }} @@ -74,17 +75,22 @@ describe('', () => { test('calls "toggleSelected" when a user changes a checkbox', (done) => { const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected'); const mockSelected = [{ name: 'foo', id: 1 }]; + const data = { + results: [ + { name: 'test instance', id: 1, url: '/foo' } + ], + count: 1, + }; const wrapper = mountWithContexts( - <_Lookup + { }} - getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })} + getItems={() => ({ data })} columns={mockColumns} sortedColumnKey="name" - handleHttpError={() => {}} /> ); setImmediate(() => { @@ -99,17 +105,22 @@ describe('', () => { test('calls "toggleSelected" when remove icon is clicked', () => { const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected'); mockData = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }]; + const data = { + results: [ + { name: 'test instance', id: 1, url: '/foo' } + ], + count: 1, + }; const wrapper = mountWithContexts( - <_Lookup + { }} - getItems={() => { }} + getItems={() => ({ data })} columns={mockColumns} sortedColumnKey="name" - handleHttpError={() => {}} /> ); const removeIcon = wrapper.find('button[aria-label="close"]').first(); @@ -121,7 +132,7 @@ describe('', () => { mockData = [{ name: 'foo', id: 0 }, { name: 'bar', id: 1 }]; const wrapper = mountWithContexts( { }} value={mockData} selected={[]} @@ -138,7 +149,7 @@ describe('', () => { mockData = []; const wrapper = mountWithContexts( { }} value={mockData} getItems={() => { }} @@ -166,11 +177,12 @@ describe('', () => { const onLookupSaveFn = jest.fn(); const wrapper = mountWithContexts( { }} + sortedColumnKey="name" /> ).find('Lookup'); wrapper.instance().toggleSelected({ @@ -188,61 +200,31 @@ describe('', () => { }], 'fooBar'); }); - test('onSort sets state and calls getData ', () => { - const spy = jest.spyOn(_Lookup.prototype, 'getData'); + test('should re-fetch data when URL params change', async () => { + const history = createMemoryHistory({ + initialEntries: ['/organizations/add'], + }); + const getItems = jest.fn(); const wrapper = mountWithContexts( <_Lookup - lookup_header="Foo Bar" + lookupHeader="Foo Bar" onLookupSave={() => { }} value={mockData} selected={[]} columns={mockColumns} sortedColumnKey="name" - getItems={() => { }} + getItems={getItems} handleHttpError={() => {}} + location={{ history }} /> - ).find('Lookup'); - wrapper.instance().onSort('id', 'descending'); - expect(wrapper.state('sortedColumnKey')).toEqual('id'); - expect(wrapper.state('sortOrder')).toEqual('descending'); - expect(spy).toHaveBeenCalled(); - }); + ); - test('onSearch calls getData (through calling onSort)', () => { - const spy = jest.spyOn(_Lookup.prototype, 'getData'); - const wrapper = mountWithContexts( - <_Lookup - lookup_header="Foo Bar" - onLookupSave={() => { }} - value={mockData} - selected={[]} - columns={mockColumns} - sortedColumnKey="name" - getItems={() => { }} - handleHttpError={() => {}} - /> - ).find('Lookup'); - wrapper.instance().onSearch(); - expect(spy).toHaveBeenCalled(); - }); - - test('onSetPage sets state and calls getData ', () => { - const spy = jest.spyOn(_Lookup.prototype, 'getData'); - const wrapper = mountWithContexts( - <_Lookup - lookup_header="Foo Bar" - onLookupSave={() => { }} - value={mockData} - selected={[]} - columns={mockColumns} - sortedColumnKey="name" - getItems={() => { }} - handleHttpError={() => {}} - /> - ).find('Lookup'); - wrapper.instance().onSetPage(2, 10); - expect(wrapper.state('page')).toEqual(2); - expect(wrapper.state('page_size')).toEqual(10); - expect(spy).toHaveBeenCalled(); + expect(getItems).toHaveBeenCalledTimes(1); + history.push('organizations/add?page=2'); + wrapper.setProps({ + location: { history }, + }); + wrapper.update(); + expect(getItems).toHaveBeenCalledTimes(2); }); }); diff --git a/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx b/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx index f148aa3ac0..5efd111cb3 100644 --- a/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx +++ b/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx @@ -12,6 +12,12 @@ const mockData = [ { id: 5, name: 'five', url: '/org/team/5' }, ]; +const qsConfig = { + namespace: 'item', + defaultParams: { page: 1, page_size: 5 }, + integerFields: [], +}; + describe('', () => { afterEach(() => { jest.restoreAllMocks(); @@ -27,11 +33,11 @@ describe('', () => { page_size: 5, order_by: 'name', }} + qsConfig={qsConfig} /> ); }); - // should navigate when datalisttoolbar changes sorting test('should navigate when DataListToolbar calls onSort prop', async () => { const history = createMemoryHistory({ initialEntries: ['/organizations/1/teams'], @@ -45,6 +51,7 @@ describe('', () => { page_size: 5, order_by: 'name', }} + qsConfig={qsConfig} />, { context: { router: { history } } } ); @@ -52,7 +59,7 @@ describe('', () => { expect(toolbar.prop('sortedColumnKey')).toEqual('name'); expect(toolbar.prop('sortOrder')).toEqual('ascending'); toolbar.prop('onSort')('name', 'descending'); - expect(history.location.search).toEqual('?order_by=-name'); + expect(history.location.search).toEqual('?item.order_by=-name'); await sleep(0); wrapper.update(); @@ -61,7 +68,7 @@ describe('', () => { // fixing after #147 is done: // expect(toolbar.prop('sortOrder')).toEqual('descending'); toolbar.prop('onSort')('name', 'ascending'); - expect(history.location.search).toEqual('?order_by=name'); + expect(history.location.search).toEqual('?item.order_by=name'); }); test('should navigate to page when Pagination calls onSetPage prop', () => { @@ -77,14 +84,15 @@ describe('', () => { page_size: 5, order_by: 'name', }} + qsConfig={qsConfig} />, { context: { router: { history } } } ); const pagination = wrapper.find('Pagination'); pagination.prop('onSetPage')(2, 5); - expect(history.location.search).toEqual('?page=2&page_size=5'); + expect(history.location.search).toEqual('?item.page=2&item.page_size=5'); wrapper.update(); pagination.prop('onSetPage')(1, 25); - expect(history.location.search).toEqual('?page=1&page_size=25'); + expect(history.location.search).toEqual('?item.page=1&item.page_size=25'); }); }); diff --git a/__tests__/components/SelectResourceStep.test.jsx b/__tests__/components/SelectResourceStep.test.jsx index 0c207b871e..6188fdf217 100644 --- a/__tests__/components/SelectResourceStep.test.jsx +++ b/__tests__/components/SelectResourceStep.test.jsx @@ -1,7 +1,9 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { shallow } from 'enzyme'; import { mountWithContexts } from '../enzymeHelpers'; -import SelectResourceStep from '../../src/components/AddRole/SelectResourceStep'; +import { sleep } from '../testUtils'; +import SelectResourceStep, { _SelectResourceStep } from '../../src/components/AddRole/SelectResourceStep'; describe('', () => { const columns = [ @@ -21,13 +23,14 @@ describe('', () => { /> ); }); + test('fetches resources on mount', async () => { const handleSearch = jest.fn().mockResolvedValue({ data: { count: 2, results: [ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' } + { id: 1, username: 'foo', url: 'item/1' }, + { id: 2, username: 'bar', url: 'item/2' } ] } }); @@ -46,102 +49,71 @@ describe('', () => { page_size: 5 }); }); + test('readResourceList properly adds rows to state', async () => { const selectedResourceRows = [ - { - id: 1, - username: 'foo' - } + { id: 1, username: 'foo', url: 'item/1' } ]; const handleSearch = jest.fn().mockResolvedValue({ data: { count: 2, results: [ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' } + { id: 1, username: 'foo', url: 'item/1' }, + { id: 2, username: 'bar', url: 'item/2' } ] } }); + const history = createMemoryHistory({ + initialEntries: ['/organizations/1/access?resource.page=1&resource.order_by=-username'], + }); const wrapper = await mountWithContexts( - {}} onSearch={handleSearch} selectedResourceRows={selectedResourceRows} sortedColumnKey="username" + location={history.location} /> ).find('SelectResourceStep'); - await wrapper.instance().readResourceList({ - page: 1, - order_by: '-username' - }); + await wrapper.instance().readResourceList(); expect(handleSearch).toHaveBeenCalledWith({ order_by: '-username', - page: 1 + page: 1, + page_size: 5, }); expect(wrapper.state('resources')).toEqual([ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' } + { id: 1, username: 'foo', url: 'item/1' }, + { id: 2, username: 'bar', url: 'item/2' } ]); }); - test('handleSetPage calls readResourceList with correct params', () => { - const spy = jest.spyOn(SelectResourceStep.prototype, 'readResourceList'); - const wrapper = mountWithContexts( - {}} - onSearch={() => {}} - selectedResourceRows={[]} - sortedColumnKey="username" - /> - ).find('SelectResourceStep'); - wrapper.setState({ sortOrder: 'descending' }); - wrapper.instance().handleSetPage(2); - expect(spy).toHaveBeenCalledWith({ page: 2, page_size: 5, order_by: '-username' }); - wrapper.setState({ sortOrder: 'ascending' }); - wrapper.instance().handleSetPage(2); - expect(spy).toHaveBeenCalledWith({ page: 2, page_size: 5, order_by: 'username' }); - }); - test('handleSort calls readResourceList with correct params', () => { - const spy = jest.spyOn(SelectResourceStep.prototype, 'readResourceList'); - const wrapper = mountWithContexts( - {}} - onSearch={() => {}} - selectedResourceRows={[]} - sortedColumnKey="username" - /> - ).find('SelectResourceStep'); - wrapper.instance().handleSort('username', 'descending'); - expect(spy).toHaveBeenCalledWith({ page: 1, page_size: 5, order_by: '-username' }); - wrapper.instance().handleSort('username', 'ascending'); - expect(spy).toHaveBeenCalledWith({ page: 1, page_size: 5, order_by: 'username' }); - }); - test('clicking on row fires callback with correct params', () => { + + test('clicking on row fires callback with correct params', async () => { const handleRowClick = jest.fn(); + const data = { + count: 2, + results: [ + { id: 1, username: 'foo', url: 'item/1' }, + { id: 2, username: 'bar', url: 'item/2' } + ] + }; const wrapper = mountWithContexts( {}} + onSearch={() => ({ data })} selectedResourceRows={[]} sortedColumnKey="username" /> ); - const selectResourceStepWrapper = wrapper.find('SelectResourceStep'); - selectResourceStepWrapper.setState({ - resources: [ - { id: 1, username: 'foo' } - ] - }); + await sleep(0); + wrapper.update(); const checkboxListItemWrapper = wrapper.find('CheckboxListItem'); - expect(checkboxListItemWrapper.length).toBe(1); - checkboxListItemWrapper.first().find('input[type="checkbox"]').simulate('change', { target: { checked: true } }); - expect(handleRowClick).toHaveBeenCalledWith({ id: 1, username: 'foo' }); + expect(checkboxListItemWrapper.length).toBe(2); + checkboxListItemWrapper.first().find('input[type="checkbox"]') + .simulate('change', { target: { checked: true } }); + expect(handleRowClick).toHaveBeenCalledWith(data.results[0]); }); }); diff --git a/__tests__/enzymeHelpers.jsx b/__tests__/enzymeHelpers.jsx index 9be34dd7ae..aa04ca5c26 100644 --- a/__tests__/enzymeHelpers.jsx +++ b/__tests__/enzymeHelpers.jsx @@ -79,30 +79,31 @@ const defaultContexts = { dialog: {} }; -const providers = { - config: ConfigProvider, - network: _NetworkProvider, - dialog: RootDialogProvider, -}; - function wrapContexts (node, context) { - let wrapped = node; - let isFirst = true; - Object.keys(providers).forEach(key => { - if (context[key]) { - const Provider = providers[key]; - wrapped = ( - - {wrapped} - + const { config, network, dialog } = context; + class Wrap extends React.Component { + render () { + // eslint-disable-next-line react/no-this-in-sfc + const { children, ...props } = this.props; + const component = React.cloneElement(children, props); + return ( + + <_NetworkProvider value={network}> + + {component} + + + ); - isFirst = false; } - }); - return wrapped; + } + + return ( + {node} + ); } function applyDefaultContexts (context) { diff --git a/__tests__/enzymeHelpers.test.jsx b/__tests__/enzymeHelpers.test.jsx index dd355d8874..3248cdbaf1 100644 --- a/__tests__/enzymeHelpers.test.jsx +++ b/__tests__/enzymeHelpers.test.jsx @@ -191,4 +191,19 @@ describe('mountWithContexts', () => { expect(dialog.setRootDialogMessage).toHaveBeenCalledWith('error'); }); }); + + it('should set props on wrapped component', () => { + function Component ({ text }) { + return (
{text}
); + } + + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('div').text()).toEqual('foo'); + wrapper.setProps({ + text: 'bar' + }); + expect(wrapper.find('div').text()).toEqual('bar'); + }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx index b418f5ed0c..7c07d21096 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx @@ -1,7 +1,5 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Router } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../enzymeHelpers'; import { sleep } from '../../../../testUtils'; import OrganizationTeams, { _OrganizationTeams } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationTeams'; @@ -68,65 +66,14 @@ describe('', () => { const list = wrapper.find('PaginatedDataList'); expect(list.prop('items')).toEqual(listData.data.results); expect(list.prop('itemCount')).toEqual(listData.data.count); - expect(list.prop('queryParams')).toEqual({ - page: 1, - page_size: 5, - order_by: 'name', - }); - }); - - test('should pass queryParams to PaginatedDataList', async () => { - const page1Data = listData; - const page2Data = { - data: { - count: 7, - results: [ - { id: 6, name: 'six', url: '/org/team/6' }, - { id: 7, name: 'seven', url: '/org/team/7' }, - ] - } - }; - const readOrganizationTeamsList = jest.fn(); - readOrganizationTeamsList.mockReturnValueOnce(page1Data); - const history = createMemoryHistory({ - initialEntries: ['/organizations/1/teams'], - }); - const wrapper = mountWithContexts( - - - , - { context: { - network: { - api: { readOrganizationTeamsList }, - handleHttpError: () => {} - }, - router: false, - } } - ); - - await sleep(0); - wrapper.update(); - - const list = wrapper.find('PaginatedDataList'); - expect(list.prop('queryParams')).toEqual({ - page: 1, - page_size: 5, - order_by: 'name', - }); - - readOrganizationTeamsList.mockReturnValueOnce(page2Data); - history.push('/organizations/1/teams?page=2'); - - await sleep(0); - wrapper.update(); - const list2 = wrapper.find('PaginatedDataList'); - expect(list2.prop('queryParams')).toEqual({ - page: 2, - page_size: 5, - order_by: 'name', + expect(list.prop('qsConfig')).toEqual({ + namespace: 'team', + defaultParams: { + page: 1, + page_size: 5, + order_by: 'name', + }, + integerFields: ['page', 'page_size'], }); }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap index f804e903a0..0f2db3c9df 100644 --- a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap +++ b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap @@ -1,432 +1,405 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` initially renders succesfully 1`] = ` - - + - - - - - - - + + - - + + - - + - - - + + -
- - - -
- - + + -
- - + + - -
- - -
- - Modified - , - - Created - , - ] - } - isOpen={false} - onSelect={[Function]} - onToggle={[Function]} - toggle={ - - Name - - } +
- initially renders succesfully 1`] = ` , ] } - forwardedComponent={ - Object { - "$$typeof": Symbol(react.forward_ref), - "attrs": Array [], - "componentStyle": ComponentStyle { - "componentId": "Search__Dropdown-sc-1dwuww3-2", - "isStatic": true, - "lastClassName": "kcnywV", - "rules": Array [ - "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", - ], - }, - "displayName": "Search__Dropdown", - "foldedComponentIds": Array [], - "render": [Function], - "styledComponentId": "Search__Dropdown-sc-1dwuww3-2", - "target": [Function], - "toString": [Function], - "warnTooManyClasses": [Function], - "withComponent": [Function], - } - } - forwardedRef={null} isOpen={false} onSelect={[Function]} onToggle={[Function]} @@ -492,9 +442,7 @@ exports[` initially renders succesfully 1`] = ` } > - initially renders succesfully 1`] = ` , ] } + forwardedComponent={ + Object { + "$$typeof": Symbol(react.forward_ref), + "attrs": Array [], + "componentStyle": ComponentStyle { + "componentId": "Search__Dropdown-sc-1dwuww3-2", + "isStatic": true, + "lastClassName": "kcnywV", + "rules": Array [ + "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", + ], + }, + "displayName": "Search__Dropdown", + "foldedComponentIds": Array [], + "render": [Function], + "styledComponentId": "Search__Dropdown-sc-1dwuww3-2", + "target": [Function], + "toString": [Function], + "warnTooManyClasses": [Function], + "withComponent": [Function], + } + } + forwardedRef={null} isOpen={false} - isPlain={false} onSelect={[Function]} onToggle={[Function]} - position="left" toggle={ initially renders succesfully 1`] = ` } > -
- - -
- } - > + Modified + , + + Created + , + ] + } + isOpen={false} + isPlain={false} + onSelect={[Function]} + onToggle={[Function]} + position="left" + toggle={ + Name + + } + > +
+ initially renders succesfully 1`] = `
} > - + +
+ } + > + + - -
- - - - - + + + + initially renders succesfully 1`] = ` type="search" value="" > - initially renders succesfully 1`] = ` type="search" value="" > - initially renders succesfully 1`] = ` } type="search" value="" - /> - - - - - + + + + + - - - - -
- - -
- -
- - -
- + + + + + + + +
+ + +
+ +
+ + +
+ -
-
-
-
- - - + + + + + + + -
- -
- - - - Modified - , - - Created - , - ] - } - isOpen={false} - onSelect={[Function]} - onToggle={[Function]} - style={ + - Name - - } + "isSortable": true, + "key": "name", + "name": "Name", + }, + Object { + "isNumeric": true, + "isSortable": true, + "key": "modified", + "name": "Modified", + }, + Object { + "isNumeric": true, + "isSortable": true, + "key": "created", + "name": "Created", + }, + ] + } + onSort={[Function]} + sortOrder="ascending" + sortedColumnKey="name" + > + - initially renders succesfully 1`] = ` , ] } - forwardedComponent={ - Object { - "$$typeof": Symbol(react.forward_ref), - "attrs": Array [], - "componentStyle": ComponentStyle { - "componentId": "Sort__Dropdown-sc-21g5aw-0", - "isStatic": true, - "lastClassName": "kdSQuN", - "rules": Array [ - "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", - ], - }, - "displayName": "Sort__Dropdown", - "foldedComponentIds": Array [], - "render": [Function], - "styledComponentId": "Sort__Dropdown-sc-21g5aw-0", - "target": [Function], - "toString": [Function], - "warnTooManyClasses": [Function], - "withComponent": [Function], - } - } - forwardedRef={null} isOpen={false} onSelect={[Function]} onToggle={[Function]} @@ -1066,9 +1011,7 @@ exports[` initially renders succesfully 1`] = ` } > - initially renders succesfully 1`] = ` , ] } + forwardedComponent={ + Object { + "$$typeof": Symbol(react.forward_ref), + "attrs": Array [], + "componentStyle": ComponentStyle { + "componentId": "Sort__Dropdown-sc-21g5aw-0", + "isStatic": true, + "lastClassName": "kdSQuN", + "rules": Array [ + "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", + ], + }, + "displayName": "Sort__Dropdown", + "foldedComponentIds": Array [], + "render": [Function], + "styledComponentId": "Sort__Dropdown-sc-21g5aw-0", + "target": [Function], + "toString": [Function], + "warnTooManyClasses": [Function], + "withComponent": [Function], + } + } + forwardedRef={null} isOpen={false} - isPlain={false} onSelect={[Function]} onToggle={[Function]} - position="left" style={ Object { "marginRight": "20px", @@ -1118,74 +1082,78 @@ exports[` initially renders succesfully 1`] = ` } > -
+ Modified + , + + Created + , + ] + } + isOpen={false} + isPlain={false} + onSelect={[Function]} onToggle={[Function]} + position="left" style={ Object { "marginRight": "20px", } } + toggle={ + + Name + + } > - - -
+ style={ + Object { + "marginRight": "20px", + } } > initially renders succesfully 1`] = `
} > - + +
+ } + > + + - - - -
- - - + + + + - - -
- -
- - - - - + + + + + + + + + + +
+ + + + + +
+ + +
+
+ + + + + + + + +
+ + +
    + + + +
  • + +
    + + + + Notification one + + + + email + + , + + + + , + ] + } + key=".0" + rowid="items-list-item-1" + > +
    + + +
    - - -
    -
    - -
    - - - -
    - - - - - -
      - - - -
    • - -
      - - - - Notification one - - - - email - - , - - - - , - ] - } - key=".0" - rowid="items-list-item-1" - > -
      - - - -
      - - - - - - Notification one - - - - - - - + Notification one + + + + + + - - - email - - - - -
      -
      -
      -
      - - + email + + + + +
      + + + + - -
      - - - - - - - - - + + + + + + + + - - - - - -
      -
      - -
      -
      - - -
      -
    • -
      -
      -
      - + + + + + + + + + + + + + + + + + + + + - - -
    • - -
      - +
      + + + + Notification two + + + + email + + , + + + , + ] + } + key=".0" + rowid="items-list-item-2" + > +
      + + - - Notification two - - - - email - - , - - - - , - ] - } - key=".0" - rowid="items-list-item-2" - > -
      - - - -
      - - - - - - Notification two - - - - - - - + Notification two + + + + + + - - - email - - - - -
      -
      -
      -
      - - + email + + + + +
      + +
      +
      + - -
      - - - - - - - - - + + + + + + + + - - - - - -
      -
      - -
      -
      -
      -
      - -
    • -
      -
      -
      -
    -
    - - + + + + + + + + + + + + + + +
    +
  • +
    +
    +
    +
+
+ -
- - - - - - - - - 50 - , - - 25 - , - - 10 - , - ] - } - isOpen={false} - isPlain={false} - onSelect={[Function]} - onToggle={[Function]} - position="left" - toggle={ - - 5 - - } - > -
- + + + + - -
- } - > + 50 + , + + 25 + , + + 10 + , + ] + } + isOpen={false} + isPlain={false} + onSelect={[Function]} + onToggle={[Function]} + position="left" + toggle={ + 5 + + } + > +
+ initially renders succesfully 1`] = `
} > - - - -
- -
-
-
- - -
} + > + + + +
+ + +
+
+ + - - - - + + + + + +
- - -
- -
-
-
-
-
-
-
-
-
-
-
+ + + +
+ + + + + + + + + + + `; diff --git a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx index 3f6b3d74cd..cd59efb01a 100644 --- a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx @@ -146,24 +146,6 @@ describe('', () => { expect(fetchOrgs).toBeCalled(); }); - test('url updates properly', () => { - const history = createMemoryHistory({ - initialEntries: ['organizations?order_by=name&page=1&page_size=5'], - }); - wrapper = mountWithContexts( - , { - context: { router: { history } } - } - ); - const component = wrapper.find('OrganizationsList'); - component.instance().updateUrl({ - page: 1, - page_size: 5, - order_by: 'modified' - }); - expect(history.location.search).toBe('?order_by=modified&page=1&page_size=5'); - }); - test('error is thrown when org not successfully deleted from api', async () => { const history = createMemoryHistory({ initialEntries: ['organizations?order_by=name&page=1&page_size=5'], diff --git a/__tests__/util/qs.test.js b/__tests__/util/qs.test.js index f884c71880..b1f94ffc86 100644 --- a/__tests__/util/qs.test.js +++ b/__tests__/util/qs.test.js @@ -1,4 +1,11 @@ -import { encodeQueryString, parseQueryString } from '../../src/util/qs'; +import { + encodeQueryString, + parseQueryString, + getQSConfig, + parseNamespacedQueryString, + encodeNamespacedQueryString, + updateNamespacedQueryString, +} from '../../src/util/qs'; describe('qs (qs.js)', () => { test('encodeQueryString returns the expected queryString', () => { @@ -23,27 +30,184 @@ describe('qs (qs.js)', () => { expect(encodeQueryString(vals)).toEqual('order_by=name'); }); - test('parseQueryString returns the expected queryParams', () => { - [ - ['order_by=name&page=1&page_size=5', ['page', 'page_size'], { order_by: 'name', page: 1, page_size: 5 }], - ['order_by=name&page=1&page_size=5', ['page_size'], { order_by: 'name', page: '1', page_size: 5 }], - ] - .forEach(([queryString, integerFields, expectedQueryParams]) => { - const actualQueryParams = parseQueryString(queryString, integerFields); + describe('parseQueryString', () => { + test('parseQueryString returns the expected queryParams', () => { + [ + ['order_by=name&page=1&page_size=5', ['page', 'page_size'], { order_by: 'name', page: 1, page_size: 5 }], + ['order_by=name&page=1&page_size=5', ['page_size'], { order_by: 'name', page: '1', page_size: 5 }], + ] + .forEach(([queryString, integerFields, expectedQueryParams]) => { + const actualQueryParams = parseQueryString(queryString, integerFields); - expect(actualQueryParams).toEqual(expectedQueryParams); - }); - }); - - test('parseQueryString should strip leading "?"', () => { - expect(parseQueryString('?foo=bar&order_by=win')).toEqual({ - foo: 'bar', - order_by: 'win', + expect(actualQueryParams).toEqual(expectedQueryParams); + }); }); - expect(parseQueryString('foo=bar&order_by=?win')).toEqual({ - foo: 'bar', - order_by: '?win', + test('parseQueryString should strip leading "?"', () => { + expect(parseQueryString('?foo=bar&order_by=win')).toEqual({ + foo: 'bar', + order_by: 'win', + }); + + expect(parseQueryString('foo=bar&order_by=?win')).toEqual({ + foo: 'bar', + order_by: '?win', + }); + }); + + test('should return empty object if no values', () => { + expect(parseQueryString('')).toEqual({}); + }); + }); + + test('should get default QS config object', () => { + expect(getQSConfig('organization')).toEqual({ + namespace: 'organization', + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size'], + }); + }); + + test('should throw if no namespace given', () => { + expect(() => getQSConfig()).toThrow(); + }); + + test('should build configured QS config object', () => { + const defaults = { + page: 1, + page_size: 15, + }; + expect(getQSConfig('inventory', defaults)).toEqual({ + namespace: 'inventory', + defaultParams: { page: 1, page_size: 15 }, + integerFields: ['page', 'page_size'], + }); + }); + + describe('parseNamespacedQueryString', () => { + test('should get query params', () => { + const config = { + namespace: null, + defaultParams: { page: 1, page_size: 15 }, + integerFields: ['page', 'page_size'], + }; + const query = '?foo=bar&page=3'; + expect(parseNamespacedQueryString(config, query)).toEqual({ + foo: 'bar', + page: 3, + page_size: 15, + }); + }); + + test('should get query params with correct integer fields', () => { + const config = { + namespace: null, + defaultParams: {}, + integerFields: ['page', 'foo'], + }; + const query = '?foo=4&bar=5'; + expect(parseNamespacedQueryString(config, query)).toEqual({ + foo: 4, + bar: '5', + }); + }); + + test('should get namespaced query params', () => { + const config = { + namespace: 'inventory', + defaultParams: { page: 1, page_size: 5 }, + integerFields: ['page', 'page_size'], + }; + const query = '?inventory.page=2&inventory.order_by=name&other=15'; + expect(parseNamespacedQueryString(config, query)).toEqual({ + page: 2, + order_by: 'name', + page_size: 5, + }); + }); + + test('should exclude other namespaced query params', () => { + const config = { + namespace: 'inventory', + defaultParams: { page: 1, page_size: 5 }, + integerFields: ['page', 'page_size'], + }; + const query = '?inventory.page=2&inventory.order_by=name&foo.other=15'; + expect(parseNamespacedQueryString(config, query)).toEqual({ + page: 2, + order_by: 'name', + page_size: 5, + }); + }); + + test('should exclude defaults if includeDefaults is false', () => { + const config = { + namespace: null, + defaultParams: { page: 1, page_size: 15 }, + integerFields: ['page', 'page_size'], + }; + const query = '?foo=bar&page=3'; + expect(parseNamespacedQueryString(config, query, false)).toEqual({ + foo: 'bar', + page: 3, + }); + }); + }); + + describe('encodeNamespacedQueryString', () => { + test('should encode params without namespace', () => { + const config = { + namespace: null, + defaultParams: { page: 1, page_size: 5 }, + integerFields: ['page', 'page_size'], + }; + const params = { + page: 1, + order_by: 'name', + }; + const qs = 'order_by=name&page=1'; + expect(encodeNamespacedQueryString(config, params)).toEqual(qs); + }); + + test('should encode params with namespace', () => { + const config = { + namespace: 'inventory', + defaultParams: { page: 1, page_size: 5 }, + integerFields: ['page', 'page_size'], + }; + const params = { + page: 1, + order_by: 'name', + }; + const qs = 'inventory.order_by=name&inventory.page=1'; + expect(encodeNamespacedQueryString(config, params)).toEqual(qs); + }); + }); + + describe('updateNamespacedQueryString', () => { + test('should return current values', () => { + const qs = '?foo=bar&inventory.page=1'; + const updated = updateNamespacedQueryString({}, qs, {}); + expect(updated).toEqual('foo=bar&inventory.page=1'); + }); + + test('should update new values', () => { + const qs = '?foo=bar&inventory.page=1'; + const updated = updateNamespacedQueryString({}, qs, { foo: 'baz' }); + expect(updated).toEqual('foo=baz&inventory.page=1'); + }); + + test('should add new values', () => { + const qs = '?foo=bar&inventory.page=1'; + const updated = updateNamespacedQueryString({}, qs, { page: 5 }); + expect(updated).toEqual('foo=bar&inventory.page=1&page=5'); + }); + + test('should update namespaced values', () => { + const qs = '?foo=bar&inventory.page=1'; + const config = { namespace: 'inventory' }; + const updated = updateNamespacedQueryString(config, qs, { page: 2 }); + expect(updated).toEqual('foo=bar&inventory.page=2'); }); }); }); diff --git a/src/components/AddRole/AddResourceRole.jsx b/src/components/AddRole/AddResourceRole.jsx index 279a3f1363..e01aaff669 100644 --- a/src/components/AddRole/AddResourceRole.jsx +++ b/src/components/AddRole/AddResourceRole.jsx @@ -186,24 +186,22 @@ class AddResourceRole extends React.Component { )} {selectedResource === 'teams' && ( )} diff --git a/src/components/AddRole/SelectResourceStep.jsx b/src/components/AddRole/SelectResourceStep.jsx index a859538f3a..c1f0a04e0b 100644 --- a/src/components/AddRole/SelectResourceStep.jsx +++ b/src/components/AddRole/SelectResourceStep.jsx @@ -1,19 +1,11 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { i18nMark } from '@lingui/react'; -import { - EmptyState, - EmptyStateBody, - EmptyStateIcon, - Title, - DataList, -} from '@patternfly/react-core'; -import { CubesIcon } from '@patternfly/react-icons'; - +import PaginatedDataList from '../PaginatedDataList'; import CheckboxListItem from '../ListItem'; -import DataListToolbar from '../DataListToolbar'; -import Pagination from '../Pagination'; import SelectedList from '../SelectedList'; +import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; const paginationStyling = { paddingLeft: '0', @@ -27,163 +19,113 @@ class SelectResourceStep extends React.Component { constructor (props) { super(props); - const { sortedColumnKey } = this.props; - this.state = { + isInitialized: false, count: null, error: false, - page: 1, - page_size: 5, resources: [], - sortOrder: 'ascending', - sortedColumnKey }; - this.handleSetPage = this.handleSetPage.bind(this); - this.handleSort = this.handleSort.bind(this); - this.readResourceList = this.readResourceList.bind(this); + this.qsConfig = getQSConfig('resource', { + page: 1, + page_size: 5, + order_by: props.sortedColumnKey, + }); } componentDidMount () { - const { page_size, page, sortedColumnKey } = this.state; - - this.readResourceList({ page_size, page, order_by: sortedColumnKey }); + this.readResourceList(); } - handleSetPage (pageNumber) { - const { page_size, sortedColumnKey, sortOrder } = this.state; - const page = parseInt(pageNumber, 10); - - let order_by = sortedColumnKey; - - if (sortOrder === 'descending') { - order_by = `-${order_by}`; + componentDidUpdate (prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.readResourceList(); } - - this.readResourceList({ page_size, page, order_by }); } - handleSort (sortedColumnKey, sortOrder) { - const { page_size } = this.state; - - let order_by = sortedColumnKey; - - if (sortOrder === 'descending') { - order_by = `-${order_by}`; - } - - this.readResourceList({ page: 1, page_size, order_by }); - } - - async readResourceList (queryParams) { - const { onSearch } = this.props; - const { page, order_by } = queryParams; - - let sortOrder = 'ascending'; - let sortedColumnKey = order_by; - - if (order_by.startsWith('-')) { - sortOrder = 'descending'; - sortedColumnKey = order_by.substring(1); - } - - this.setState({ error: false }); + async readResourceList () { + const { onSearch, location } = this.props; + const queryParams = parseNamespacedQueryString(this.qsConfig, location.search); + this.setState({ + isLoading: true, + error: false, + }); try { const { data } = await onSearch(queryParams); const { count, results } = data; - const stateToUpdate = { - count, - page, + this.setState({ resources: results, - sortOrder, - sortedColumnKey - }; - - this.setState(stateToUpdate); + count, + isInitialized: true, + isLoading: false, + error: false, + }); } catch (err) { - this.setState({ error: true }); + this.setState({ + isLoading: false, + error: true, + }); } } render () { const { + isInitialized, + isLoading, count, error, - page, - page_size, resources, - sortOrder, - sortedColumnKey } = this.state; const { columns, displayKey, - emptyListBody, - emptyListTitle, onRowClick, selectedLabel, - selectedResourceRows + selectedResourceRows, + itemName, } = this.props; return ( - - {(resources.length === 0) ? ( - - - - {emptyListTitle} - - - {emptyListBody} - - - ) : ( - - {selectedResourceRows.length > 0 && ( - Loading...)} + {isInitialized && ( + + {selectedResourceRows.length > 0 && ( + + )} + ( + i.id === item.id)} + itemId={item.id} + key={item.id} + name={item[displayKey]} + onSelect={() => onRowClick(item)} /> )} - - - {resources.map(i => ( - item.id === i.id)} - itemId={i.id} - key={i.id} - name={i[displayKey]} - onSelect={() => onRowClick(i)} - /> - ))} - - - - )} - + alignToolbarLeft + showPageSizeOptions={false} + paginationStyling={paginationStyling} + /> + + )} { error ?
error
: '' }
); @@ -193,23 +135,22 @@ class SelectResourceStep extends React.Component { SelectResourceStep.propTypes = { columns: PropTypes.arrayOf(PropTypes.object).isRequired, displayKey: PropTypes.string, - emptyListBody: PropTypes.string, - emptyListTitle: PropTypes.string, onRowClick: PropTypes.func, onSearch: PropTypes.func.isRequired, selectedLabel: PropTypes.string, selectedResourceRows: PropTypes.arrayOf(PropTypes.object), - sortedColumnKey: PropTypes.string + sortedColumnKey: PropTypes.string, + itemName: PropTypes.string, }; SelectResourceStep.defaultProps = { displayKey: 'name', - emptyListBody: i18nMark('Please add items to populate this list'), - emptyListTitle: i18nMark('No Items Found'), onRowClick: () => {}, selectedLabel: i18nMark('Selected Items'), selectedResourceRows: [], - sortedColumnKey: 'name' + sortedColumnKey: 'name', + itemName: 'item', }; -export default SelectResourceStep; +export { SelectResourceStep as _SelectResourceStep }; +export default withRouter(SelectResourceStep); diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx index 6d74e96e55..e007ed2af5 100644 --- a/src/components/Lookup/Lookup.jsx +++ b/src/components/Lookup/Lookup.jsx @@ -1,26 +1,22 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import { SearchIcon, CubesIcon } from '@patternfly/react-icons'; +import { withRouter } from 'react-router-dom'; +import { SearchIcon } from '@patternfly/react-icons'; import { Button, ButtonVariant, Chip, - EmptyState, - EmptyStateBody, - EmptyStateIcon, InputGroup, Modal, - Title } from '@patternfly/react-core'; import { I18n } from '@lingui/react'; -import { Trans, t } from '@lingui/macro'; +import { t } from '@lingui/macro'; import { withNetwork } from '../../contexts/Network'; - +import PaginatedDataList from '../PaginatedDataList'; import CheckboxListItem from '../ListItem'; -import DataListToolbar from '../DataListToolbar'; import SelectedList from '../SelectedList'; -import Pagination from '../Pagination'; +import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; const paginationStyling = { paddingLeft: '0', @@ -39,71 +35,48 @@ class Lookup extends React.Component { lookupSelectedItems: [...props.value] || [], results: [], count: 0, + error: null, + }; + this.qsConfig = getQSConfig('lookup', { page: 1, page_size: 5, - error: null, - sortOrder: 'ascending', - sortedColumnKey: props.sortedColumnKey - }; - this.onSetPage = this.onSetPage.bind(this); + order_by: props.sortedColumnKey, + }); this.handleModalToggle = this.handleModalToggle.bind(this); this.toggleSelected = this.toggleSelected.bind(this); this.saveModal = this.saveModal.bind(this); this.getData = this.getData.bind(this); - this.onSearch = this.onSearch.bind(this); - this.onSort = this.onSort.bind(this); } componentDidMount () { - const { page_size, page } = this.state; - this.getData({ page_size, page }); + this.getData(); } - onSearch () { - const { sortedColumnKey, sortOrder } = this.state; - this.onSort(sortedColumnKey, sortOrder); - } - - onSort (sortedColumnKey, sortOrder) { - this.setState({ page: 1, sortedColumnKey, sortOrder }, this.getData); + componentDidUpdate (prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.getData(); + } } async getData () { - const { getItems, handleHttpError } = this.props; - const { page, page_size, sortedColumnKey, sortOrder } = this.state; + const { getItems, handleHttpError, location } = this.props; + const queryParams = parseNamespacedQueryString(this.qsConfig, location.search); this.setState({ error: false }); - - const queryParams = { - page, - page_size - }; - - if (sortedColumnKey) { - queryParams.order_by = sortOrder === 'descending' ? `-${sortedColumnKey}` : sortedColumnKey; - } - try { const { data } = await getItems(queryParams); const { results, count } = data; - const stateToUpdate = { + this.setState({ results, count - }; - - this.setState(stateToUpdate); + }); } catch (err) { handleHttpError(err) || this.setState({ error: true }); } } - onSetPage = async (pageNumber, pageSize) => { - const page = parseInt(pageNumber, 10); - const page_size = parseInt(pageSize, 10); - this.setState({ page, page_size }, this.getData); - }; - toggleSelected (row) { const { name, onLookupSave } = this.props; const { lookupSelectedItems: updatedSelectedItems, isModalOpen } = this.state; @@ -156,10 +129,6 @@ class Lookup extends React.Component { error, results, count, - page, - page_size, - sortedColumnKey, - sortOrder } = this.state; const { id, lookupHeader = 'items', value, columns } = this.props; @@ -200,49 +169,25 @@ class Lookup extends React.Component { ]} > - {(results.length === 0) ? ( - - - - <Trans>{`No ${lookupHeader} Found`}</Trans> - - - {`Please add ${lookupHeader.toLowerCase()} to populate this list`} - - - ) : ( - - ( + i.id === item.id)} + onSelect={() => this.toggleSelected(item)} /> -
    - {results.map(i => ( - item.id === i.id)} - onSelect={() => this.toggleSelected(i)} - /> - ))} -
- -
- )} + )} + alignToolbarLeft + showPageSizeOptions={false} + paginationStyling={paginationStyling} + /> {lookupSelectedItems.length > 0 && ( {({ i18n }) => ( @@ -153,6 +160,7 @@ class PaginatedDataList extends React.Component { isAllSelected={isAllSelected} onSelectAll={onSelectAll} additionalControls={additionalControls} + noLeftMargin={alignToolbarLeft} /> {items.map(item => (renderItem ? renderItem(item) : ( @@ -182,10 +190,12 @@ class PaginatedDataList extends React.Component { )} @@ -202,19 +212,12 @@ const Item = PropTypes.shape({ name: PropTypes.string, }); -const QueryParams = PropTypes.shape({ - page: PropTypes.number, - page_size: PropTypes.number, - order_by: PropTypes.string, -}); - PaginatedDataList.propTypes = { items: PropTypes.arrayOf(Item).isRequired, itemCount: PropTypes.number.isRequired, itemName: PropTypes.string, itemNamePlural: PropTypes.string, - // TODO: determine this internally but pass in defaults? - queryParams: QueryParams.isRequired, + qsConfig: QSConfig.isRequired, renderItem: PropTypes.func, toolbarColumns: arrayOf(shape({ name: string.isRequired, @@ -225,6 +228,9 @@ PaginatedDataList.propTypes = { showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, onSelectAll: PropTypes.func, + alignToolbarLeft: PropTypes.bool, + showPageSizeOptions: PropTypes.bool, + paginationStyling: PropTypes.shape(), }; PaginatedDataList.defaultProps = { @@ -238,6 +244,9 @@ PaginatedDataList.defaultProps = { showSelectAll: false, isAllSelected: false, onSelectAll: null, + alignToolbarLeft: false, + showPageSizeOptions: true, + paginationStyling: null, }; export { PaginatedDataList as _PaginatedDataList }; diff --git a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx index a6e9077610..ad2fc69d7e 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx @@ -6,14 +6,14 @@ import OrganizationAccessItem from '../../components/OrganizationAccessItem'; import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal'; import AddResourceRole from '../../../../components/AddRole/AddResourceRole'; import { withNetwork } from '../../../../contexts/Network'; -import { parseQueryString } from '../../../../util/qs'; +import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; import { Organization } from '../../../../types'; -const DEFAULT_QUERY_PARAMS = { +const QS_CONFIG = getQSConfig('access', { page: 1, page_size: 5, order_by: 'first_name', -}; +}); class OrganizationAccess extends React.Component { static propTypes = { @@ -54,12 +54,12 @@ class OrganizationAccess extends React.Component { } async readOrgAccessList () { - const { organization, api, handleHttpError } = this.props; + const { organization, api, handleHttpError, location } = this.props; this.setState({ isLoading: true }); try { const { data } = await api.getOrganizationAccessList( organization.id, - this.getQueryParams() + parseNamespacedQueryString(QS_CONFIG, location.search) ); this.setState({ itemCount: data.count || 0, @@ -75,16 +75,6 @@ class OrganizationAccess extends React.Component { } } - getQueryParams () { - const { location } = this.props; - const searchParams = parseQueryString(location.search.substring(1)); - - return { - ...DEFAULT_QUERY_PARAMS, - ...searchParams, - }; - } - confirmRemoveRole (role, accessRecord) { this.setState({ roleToDelete: role, @@ -175,7 +165,7 @@ class OrganizationAccess extends React.Component { items={accessRecords} itemCount={itemCount} itemName="role" - queryParams={this.getQueryParams()} + qsConfig={QS_CONFIG} toolbarColumns={[ { name: i18nMark('Name'), key: 'first_name', isSortable: true }, { name: i18nMark('Username'), key: 'username', isSortable: true }, diff --git a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx index c3129775be..59e5b43eb9 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx @@ -4,13 +4,13 @@ import { withRouter } from 'react-router-dom'; import { withNetwork } from '../../../../contexts/Network'; import PaginatedDataList from '../../../../components/PaginatedDataList'; import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem'; -import { parseQueryString } from '../../../../util/qs'; +import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; -const DEFAULT_QUERY_PARAMS = { +const QS_CONFIG = getQSConfig('notification', { page: 1, page_size: 5, order_by: 'name', -}; +}); const COLUMNS = [ { key: 'name', name: 'Name', isSortable: true }, @@ -48,19 +48,9 @@ class OrganizationNotifications extends Component { } } - getQueryParams () { - const { location } = this.props; - const searchParams = parseQueryString(location.search.substring(1)); - - return { - ...DEFAULT_QUERY_PARAMS, - ...searchParams, - }; - } - async readNotifications () { - const { api, handleHttpError, id } = this.props; - const params = this.getQueryParams(); + const { id, api, handleHttpError, location } = this.props; + const params = parseNamespacedQueryString(QS_CONFIG, location.search); this.setState({ isLoading: true }); try { const { data } = await api.getOrganizationNotifications(id, params); @@ -191,7 +181,7 @@ class OrganizationNotifications extends Component { items={notifications} itemCount={itemCount} itemName="notification" - queryParams={this.getQueryParams()} + qsConfig={QS_CONFIG} toolbarColumns={COLUMNS} renderItem={(notification) => ( )} diff --git a/src/pages/Organizations/screens/OrganizationsList.jsx b/src/pages/Organizations/screens/OrganizationsList.jsx index 5a903e6c37..6465017fd0 100644 --- a/src/pages/Organizations/screens/OrganizationsList.jsx +++ b/src/pages/Organizations/screens/OrganizationsList.jsx @@ -13,7 +13,7 @@ import PaginatedDataList, { ToolbarAddButton } from '../../../components/PaginatedDataList'; import OrganizationListItem from '../components/OrganizationListItem'; -import { encodeQueryString, parseQueryString } from '../../../util/qs'; +import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs'; const COLUMNS = [ { name: i18nMark('Name'), key: 'name', isSortable: true }, @@ -21,11 +21,11 @@ const COLUMNS = [ { name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true }, ]; -const DEFAULT_QUERY_PARAMS = { +const QS_CONFIG = getQSConfig('organization', { page: 1, page_size: 5, order_by: 'name', -}; +}); class OrganizationsList extends Component { constructor (props) { @@ -39,10 +39,8 @@ class OrganizationsList extends Component { selected: [], }; - this.getQueryParams = this.getQueryParams.bind(this); this.handleSelectAll = this.handleSelectAll.bind(this); this.handleSelect = this.handleSelect.bind(this); - this.updateUrl = this.updateUrl.bind(this); this.fetchOptionsOrganizations = this.fetchOptionsOrganizations.bind(this); this.fetchOrganizations = this.fetchOrganizations.bind(this); this.handleOrgDelete = this.handleOrgDelete.bind(this); @@ -77,16 +75,6 @@ class OrganizationsList extends Component { } } - getQueryParams () { - const { location } = this.props; - const searchParams = parseQueryString(location.search.substring(1)); - - return { - ...DEFAULT_QUERY_PARAMS, - ...searchParams, - }; - } - async handleOrgDelete () { const { selected } = this.state; const { api, handleHttpError } = this.props; @@ -101,25 +89,14 @@ class OrganizationsList extends Component { errorHandled = handleHttpError(err); } finally { if (!errorHandled) { - const queryParams = this.getQueryParams(); - this.fetchOrganizations(queryParams); + this.fetchOrganizations(); } } } - updateUrl (queryParams) { - const { history, location } = this.props; - const pathname = '/organizations'; - const search = `?${encodeQueryString(queryParams)}`; - - if (search !== location.search) { - history.replace({ pathname, search }); - } - } - async fetchOrganizations () { - const { api, handleHttpError } = this.props; - const params = this.getQueryParams(); + const { api, handleHttpError, location } = this.props; + const params = parseNamespacedQueryString(QS_CONFIG, location.search); this.setState({ error: false, isLoading: true }); @@ -185,7 +162,7 @@ class OrganizationsList extends Component { items={organizations} itemCount={itemCount} itemName="organization" - queryParams={this.getQueryParams()} + qsConfig={QS_CONFIG} toolbarColumns={COLUMNS} showSelectAll isAllSelected={isAllSelected} diff --git a/src/types.js b/src/types.js index b1a0a353a4..d97ceacb8f 100644 --- a/src/types.js +++ b/src/types.js @@ -47,3 +47,9 @@ export const Organization = shape({ created: string, modified: string, }); + +export const QSConfig = shape({ + defaultParams: shape().isRequired, + namespace: string, + integerFields: arrayOf(string).isRequired, +}); diff --git a/src/util/qs.js b/src/util/qs.js index 79ecfe040d..43b984f605 100644 --- a/src/util/qs.js +++ b/src/util/qs.js @@ -40,3 +40,74 @@ export const parseQueryString = (queryString, integerFields = ['page', 'page_siz return Object.assign(...keyValuePairs.map(([k, v]) => ({ [k]: v }))); }; + +export function getQSConfig ( + namespace, + defaultParams = { page: 1, page_size: 5, order_by: 'name' }, + integerFields = ['page', 'page_size'] +) { + if (!namespace) { + throw new Error('a QS namespace is required'); + } + return { + defaultParams, + namespace, + integerFields, + }; +} + +export function encodeNamespacedQueryString (config, params) { + return encodeQueryString(namespaceParams(config.namespace, params)); +} + +export function parseNamespacedQueryString (config, queryString, includeDefaults = true) { + const integerFields = prependNamespaceToArray(config.namespace, config.integerFields); + const parsed = parseQueryString(queryString, integerFields); + + const namespace = {}; + Object.keys(parsed).forEach(field => { + if (namespaceMatches(config.namespace, field)) { + let fieldname = field; + if (config.namespace) { + fieldname = field.substr(config.namespace.length + 1); + } + namespace[fieldname] = parsed[field]; + } + }); + return { + ...includeDefaults ? config.defaultParams : {}, + ...namespace, + }; +} + +export function updateNamespacedQueryString (config, queryString, newParams) { + const params = parseQueryString(queryString); + return encodeQueryString({ + ...params, + ...namespaceParams(config.namespace, newParams), + }); +} + +function namespaceParams (ns, params) { + if (!ns) return params; + + const namespaced = {}; + Object.keys(params).forEach(key => { + namespaced[`${ns}.${key}`] = params[key]; + }); + return namespaced; +} + +function namespaceMatches (namespace, fieldname) { + if (!namespace) { + return !fieldname.includes('.'); + } + return fieldname.startsWith(`${namespace}.`); +} + +function prependNamespaceToArray (namespace, arr) { + if (!namespace) { + return arr; + } + return arr.map(f => `${namespace}.${f}`); +}