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
This commit is contained in:
Keith Grant
2019-05-15 10:06:14 -04:00
committed by GitHub
parent d59975c1ad
commit 4407aeac20
19 changed files with 2656 additions and 2648 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../enzymeHelpers'; import { mountWithContexts } from '../enzymeHelpers';
import Lookup from '../../src/components/Lookup'; import Lookup from '../../src/components/Lookup';
import { _Lookup } from '../../src/components/Lookup/Lookup'; import { _Lookup } from '../../src/components/Lookup/Lookup';
@@ -10,8 +11,8 @@ const mockColumns = [
describe('<Lookup />', () => { describe('<Lookup />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(
<_Lookup <Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockData} value={mockData}
onLookupSave={() => { }} onLookupSave={() => { }}
@@ -25,8 +26,8 @@ describe('<Lookup />', () => {
test('API response is formatted properly', (done) => { test('API response is formatted properly', (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<_Lookup <Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockData} value={mockData}
onLookupSave={() => { }} onLookupSave={() => { }}
@@ -47,9 +48,9 @@ describe('<Lookup />', () => {
const spy = jest.spyOn(_Lookup.prototype, 'handleModalToggle'); const spy = jest.spyOn(_Lookup.prototype, 'handleModalToggle');
const mockSelected = [{ name: 'foo', id: 1 }]; const mockSelected = [{ name: 'foo', id: 1 }];
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<_Lookup <Lookup
id="search" id="search"
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockSelected} value={mockSelected}
onLookupSave={() => { }} onLookupSave={() => { }}
@@ -74,17 +75,22 @@ describe('<Lookup />', () => {
test('calls "toggleSelected" when a user changes a checkbox', (done) => { test('calls "toggleSelected" when a user changes a checkbox', (done) => {
const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected'); const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected');
const mockSelected = [{ name: 'foo', id: 1 }]; const mockSelected = [{ name: 'foo', id: 1 }];
const data = {
results: [
{ name: 'test instance', id: 1, url: '/foo' }
],
count: 1,
};
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<_Lookup <Lookup
id="search" id="search"
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockSelected} value={mockSelected}
onLookupSave={() => { }} onLookupSave={() => { }}
getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })} getItems={() => ({ data })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
handleHttpError={() => {}}
/> />
); );
setImmediate(() => { setImmediate(() => {
@@ -99,17 +105,22 @@ describe('<Lookup />', () => {
test('calls "toggleSelected" when remove icon is clicked', () => { test('calls "toggleSelected" when remove icon is clicked', () => {
const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected'); const spy = jest.spyOn(_Lookup.prototype, 'toggleSelected');
mockData = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }]; mockData = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }];
const data = {
results: [
{ name: 'test instance', id: 1, url: '/foo' }
],
count: 1,
};
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<_Lookup <Lookup
id="search" id="search"
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockData} value={mockData}
onLookupSave={() => { }} onLookupSave={() => { }}
getItems={() => { }} getItems={() => ({ data })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
handleHttpError={() => {}}
/> />
); );
const removeIcon = wrapper.find('button[aria-label="close"]').first(); const removeIcon = wrapper.find('button[aria-label="close"]').first();
@@ -121,7 +132,7 @@ describe('<Lookup />', () => {
mockData = [{ name: 'foo', id: 0 }, { name: 'bar', id: 1 }]; mockData = [{ name: 'foo', id: 0 }, { name: 'bar', id: 1 }];
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<Lookup <Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
onLookupSave={() => { }} onLookupSave={() => { }}
value={mockData} value={mockData}
selected={[]} selected={[]}
@@ -138,7 +149,7 @@ describe('<Lookup />', () => {
mockData = []; mockData = [];
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<Lookup <Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
onLookupSave={() => { }} onLookupSave={() => { }}
value={mockData} value={mockData}
getItems={() => { }} getItems={() => { }}
@@ -166,11 +177,12 @@ describe('<Lookup />', () => {
const onLookupSaveFn = jest.fn(); const onLookupSaveFn = jest.fn();
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<Lookup <Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
name="fooBar" name="fooBar"
value={mockData} value={mockData}
onLookupSave={onLookupSaveFn} onLookupSave={onLookupSaveFn}
getItems={() => { }} getItems={() => { }}
sortedColumnKey="name"
/> />
).find('Lookup'); ).find('Lookup');
wrapper.instance().toggleSelected({ wrapper.instance().toggleSelected({
@@ -188,61 +200,31 @@ describe('<Lookup />', () => {
}], 'fooBar'); }], 'fooBar');
}); });
test('onSort sets state and calls getData ', () => { test('should re-fetch data when URL params change', async () => {
const spy = jest.spyOn(_Lookup.prototype, 'getData'); const history = createMemoryHistory({
initialEntries: ['/organizations/add'],
});
const getItems = jest.fn();
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<_Lookup <_Lookup
lookup_header="Foo Bar" lookupHeader="Foo Bar"
onLookupSave={() => { }} onLookupSave={() => { }}
value={mockData} value={mockData}
selected={[]} selected={[]}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
getItems={() => { }} getItems={getItems}
handleHttpError={() => {}} 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)', () => { expect(getItems).toHaveBeenCalledTimes(1);
const spy = jest.spyOn(_Lookup.prototype, 'getData'); history.push('organizations/add?page=2');
const wrapper = mountWithContexts( wrapper.setProps({
<_Lookup location: { history },
lookup_header="Foo Bar" });
onLookupSave={() => { }} wrapper.update();
value={mockData} expect(getItems).toHaveBeenCalledTimes(2);
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();
}); });
}); });

View File

@@ -12,6 +12,12 @@ const mockData = [
{ id: 5, name: 'five', url: '/org/team/5' }, { id: 5, name: 'five', url: '/org/team/5' },
]; ];
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5 },
integerFields: [],
};
describe('<PaginatedDataList />', () => { describe('<PaginatedDataList />', () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
@@ -27,11 +33,11 @@ describe('<PaginatedDataList />', () => {
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}} }}
qsConfig={qsConfig}
/> />
); );
}); });
// should navigate when datalisttoolbar changes sorting
test('should navigate when DataListToolbar calls onSort prop', async () => { test('should navigate when DataListToolbar calls onSort prop', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/organizations/1/teams'], initialEntries: ['/organizations/1/teams'],
@@ -45,6 +51,7 @@ describe('<PaginatedDataList />', () => {
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}} }}
qsConfig={qsConfig}
/>, { context: { router: { history } } } />, { context: { router: { history } } }
); );
@@ -52,7 +59,7 @@ describe('<PaginatedDataList />', () => {
expect(toolbar.prop('sortedColumnKey')).toEqual('name'); expect(toolbar.prop('sortedColumnKey')).toEqual('name');
expect(toolbar.prop('sortOrder')).toEqual('ascending'); expect(toolbar.prop('sortOrder')).toEqual('ascending');
toolbar.prop('onSort')('name', 'descending'); 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); await sleep(0);
wrapper.update(); wrapper.update();
@@ -61,7 +68,7 @@ describe('<PaginatedDataList />', () => {
// fixing after #147 is done: // fixing after #147 is done:
// expect(toolbar.prop('sortOrder')).toEqual('descending'); // expect(toolbar.prop('sortOrder')).toEqual('descending');
toolbar.prop('onSort')('name', 'ascending'); 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', () => { test('should navigate to page when Pagination calls onSetPage prop', () => {
@@ -77,14 +84,15 @@ describe('<PaginatedDataList />', () => {
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}} }}
qsConfig={qsConfig}
/>, { context: { router: { history } } } />, { context: { router: { history } } }
); );
const pagination = wrapper.find('Pagination'); const pagination = wrapper.find('Pagination');
pagination.prop('onSetPage')(2, 5); 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(); wrapper.update();
pagination.prop('onSetPage')(1, 25); 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');
}); });
}); });

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { mountWithContexts } from '../enzymeHelpers'; import { mountWithContexts } from '../enzymeHelpers';
import SelectResourceStep from '../../src/components/AddRole/SelectResourceStep'; import { sleep } from '../testUtils';
import SelectResourceStep, { _SelectResourceStep } from '../../src/components/AddRole/SelectResourceStep';
describe('<SelectResourceStep />', () => { describe('<SelectResourceStep />', () => {
const columns = [ const columns = [
@@ -21,13 +23,14 @@ describe('<SelectResourceStep />', () => {
/> />
); );
}); });
test('fetches resources on mount', async () => { test('fetches resources on mount', async () => {
const handleSearch = jest.fn().mockResolvedValue({ const handleSearch = jest.fn().mockResolvedValue({
data: { data: {
count: 2, count: 2,
results: [ results: [
{ id: 1, username: 'foo' }, { id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar' } { id: 2, username: 'bar', url: 'item/2' }
] ]
} }
}); });
@@ -46,102 +49,71 @@ describe('<SelectResourceStep />', () => {
page_size: 5 page_size: 5
}); });
}); });
test('readResourceList properly adds rows to state', async () => { test('readResourceList properly adds rows to state', async () => {
const selectedResourceRows = [ const selectedResourceRows = [
{ { id: 1, username: 'foo', url: 'item/1' }
id: 1,
username: 'foo'
}
]; ];
const handleSearch = jest.fn().mockResolvedValue({ const handleSearch = jest.fn().mockResolvedValue({
data: { data: {
count: 2, count: 2,
results: [ results: [
{ id: 1, username: 'foo' }, { id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar' } { 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( const wrapper = await mountWithContexts(
<SelectResourceStep <_SelectResourceStep
columns={columns} columns={columns}
displayKey="username" displayKey="username"
onRowClick={() => {}} onRowClick={() => {}}
onSearch={handleSearch} onSearch={handleSearch}
selectedResourceRows={selectedResourceRows} selectedResourceRows={selectedResourceRows}
sortedColumnKey="username" sortedColumnKey="username"
location={history.location}
/> />
).find('SelectResourceStep'); ).find('SelectResourceStep');
await wrapper.instance().readResourceList({ await wrapper.instance().readResourceList();
page: 1,
order_by: '-username'
});
expect(handleSearch).toHaveBeenCalledWith({ expect(handleSearch).toHaveBeenCalledWith({
order_by: '-username', order_by: '-username',
page: 1 page: 1,
page_size: 5,
}); });
expect(wrapper.state('resources')).toEqual([ expect(wrapper.state('resources')).toEqual([
{ id: 1, username: 'foo' }, { id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar' } { id: 2, username: 'bar', url: 'item/2' }
]); ]);
}); });
test('handleSetPage calls readResourceList with correct params', () => {
const spy = jest.spyOn(SelectResourceStep.prototype, 'readResourceList'); test('clicking on row fires callback with correct params', async () => {
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
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(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
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', () => {
const handleRowClick = jest.fn(); 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( const wrapper = mountWithContexts(
<SelectResourceStep <SelectResourceStep
columns={columns} columns={columns}
displayKey="username" displayKey="username"
onRowClick={handleRowClick} onRowClick={handleRowClick}
onSearch={() => {}} onSearch={() => ({ data })}
selectedResourceRows={[]} selectedResourceRows={[]}
sortedColumnKey="username" sortedColumnKey="username"
/> />
); );
const selectResourceStepWrapper = wrapper.find('SelectResourceStep'); await sleep(0);
selectResourceStepWrapper.setState({ wrapper.update();
resources: [
{ id: 1, username: 'foo' }
]
});
const checkboxListItemWrapper = wrapper.find('CheckboxListItem'); const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
expect(checkboxListItemWrapper.length).toBe(1); expect(checkboxListItemWrapper.length).toBe(2);
checkboxListItemWrapper.first().find('input[type="checkbox"]').simulate('change', { target: { checked: true } }); checkboxListItemWrapper.first().find('input[type="checkbox"]')
expect(handleRowClick).toHaveBeenCalledWith({ id: 1, username: 'foo' }); .simulate('change', { target: { checked: true } });
expect(handleRowClick).toHaveBeenCalledWith(data.results[0]);
}); });
}); });

View File

@@ -79,30 +79,31 @@ const defaultContexts = {
dialog: {} dialog: {}
}; };
const providers = {
config: ConfigProvider,
network: _NetworkProvider,
dialog: RootDialogProvider,
};
function wrapContexts (node, context) { function wrapContexts (node, context) {
let wrapped = node; const { config, network, dialog } = context;
let isFirst = true; class Wrap extends React.Component {
Object.keys(providers).forEach(key => { render () {
if (context[key]) { // eslint-disable-next-line react/no-this-in-sfc
const Provider = providers[key]; const { children, ...props } = this.props;
wrapped = ( const component = React.cloneElement(children, props);
<Provider return (
value={context[key]} <RootDialogProvider value={dialog}>
i18n={isFirst ? defaultContexts.linguiPublisher.i18n : null} <_NetworkProvider value={network}>
> <ConfigProvider
{wrapped} value={config}
</Provider> i18n={defaultContexts.linguiPublisher.i18n}
>
{component}
</ConfigProvider>
</_NetworkProvider>
</RootDialogProvider>
); );
isFirst = false;
} }
}); }
return wrapped;
return (
<Wrap>{node}</Wrap>
);
} }
function applyDefaultContexts (context) { function applyDefaultContexts (context) {

View File

@@ -191,4 +191,19 @@ describe('mountWithContexts', () => {
expect(dialog.setRootDialogMessage).toHaveBeenCalledWith('error'); expect(dialog.setRootDialogMessage).toHaveBeenCalledWith('error');
}); });
}); });
it('should set props on wrapped component', () => {
function Component ({ text }) {
return (<div>{text}</div>);
}
const wrapper = mountWithContexts(
<Component text="foo" />
);
expect(wrapper.find('div').text()).toEqual('foo');
wrapper.setProps({
text: 'bar'
});
expect(wrapper.find('div').text()).toEqual('bar');
});
}); });

View File

@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../enzymeHelpers'; import { mountWithContexts } from '../../../../enzymeHelpers';
import { sleep } from '../../../../testUtils'; import { sleep } from '../../../../testUtils';
import OrganizationTeams, { _OrganizationTeams } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationTeams'; import OrganizationTeams, { _OrganizationTeams } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationTeams';
@@ -68,65 +66,14 @@ describe('<OrganizationTeams />', () => {
const list = wrapper.find('PaginatedDataList'); const list = wrapper.find('PaginatedDataList');
expect(list.prop('items')).toEqual(listData.data.results); expect(list.prop('items')).toEqual(listData.data.results);
expect(list.prop('itemCount')).toEqual(listData.data.count); expect(list.prop('itemCount')).toEqual(listData.data.count);
expect(list.prop('queryParams')).toEqual({ expect(list.prop('qsConfig')).toEqual({
page: 1, namespace: 'team',
page_size: 5, defaultParams: {
order_by: 'name', page: 1,
}); page_size: 5,
}); order_by: 'name',
},
test('should pass queryParams to PaginatedDataList', async () => { integerFields: ['page', 'page_size'],
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(
<Router history={history}>
<OrganizationTeams
id={1}
searchString=""
/>
</Router>,
{ 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',
}); });
}); });
}); });

View File

@@ -146,24 +146,6 @@ describe('<OrganizationsList />', () => {
expect(fetchOrgs).toBeCalled(); expect(fetchOrgs).toBeCalled();
}); });
test('url updates properly', () => {
const history = createMemoryHistory({
initialEntries: ['organizations?order_by=name&page=1&page_size=5'],
});
wrapper = mountWithContexts(
<OrganizationsList />, {
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 () => { test('error is thrown when org not successfully deleted from api', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['organizations?order_by=name&page=1&page_size=5'], initialEntries: ['organizations?order_by=name&page=1&page_size=5'],

View File

@@ -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)', () => { describe('qs (qs.js)', () => {
test('encodeQueryString returns the expected queryString', () => { test('encodeQueryString returns the expected queryString', () => {
@@ -23,27 +30,184 @@ describe('qs (qs.js)', () => {
expect(encodeQueryString(vals)).toEqual('order_by=name'); expect(encodeQueryString(vals)).toEqual('order_by=name');
}); });
test('parseQueryString returns the expected queryParams', () => { 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 }], ['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); .forEach(([queryString, integerFields, expectedQueryParams]) => {
const actualQueryParams = parseQueryString(queryString, integerFields);
expect(actualQueryParams).toEqual(expectedQueryParams); expect(actualQueryParams).toEqual(expectedQueryParams);
}); });
});
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({ test('parseQueryString should strip leading "?"', () => {
foo: 'bar', expect(parseQueryString('?foo=bar&order_by=win')).toEqual({
order_by: '?win', 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');
}); });
}); });
}); });

View File

@@ -186,24 +186,22 @@ class AddResourceRole extends React.Component {
<SelectResourceStep <SelectResourceStep
columns={userColumns} columns={userColumns}
displayKey="username" displayKey="username"
emptyListBody={i18n._(t`Please add users to populate this list`)}
emptyListTitle={i18n._(t`No Users Found`)}
onRowClick={this.handleResourceCheckboxClick} onRowClick={this.handleResourceCheckboxClick}
onSearch={this.readUsers} onSearch={this.readUsers}
selectedLabel={i18n._(t`Selected`)} selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows} selectedResourceRows={selectedResourceRows}
sortedColumnKey="username" sortedColumnKey="username"
itemName="user"
/> />
)} )}
{selectedResource === 'teams' && ( {selectedResource === 'teams' && (
<SelectResourceStep <SelectResourceStep
columns={teamColumns} columns={teamColumns}
emptyListBody={i18n._(t`Please add teams to populate this list`)}
emptyListTitle={i18n._(t`No Teams Found`)}
onRowClick={this.handleResourceCheckboxClick} onRowClick={this.handleResourceCheckboxClick}
onSearch={this.readTeams} onSearch={this.readTeams}
selectedLabel={i18n._(t`Selected`)} selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows} selectedResourceRows={selectedResourceRows}
itemName="team"
/> />
)} )}
</Fragment> </Fragment>

View File

@@ -1,19 +1,11 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { i18nMark } from '@lingui/react'; import { i18nMark } from '@lingui/react';
import { import PaginatedDataList from '../PaginatedDataList';
EmptyState,
EmptyStateBody,
EmptyStateIcon,
Title,
DataList,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import CheckboxListItem from '../ListItem'; import CheckboxListItem from '../ListItem';
import DataListToolbar from '../DataListToolbar';
import Pagination from '../Pagination';
import SelectedList from '../SelectedList'; import SelectedList from '../SelectedList';
import { getQSConfig, parseNamespacedQueryString } from '../../util/qs';
const paginationStyling = { const paginationStyling = {
paddingLeft: '0', paddingLeft: '0',
@@ -27,163 +19,113 @@ class SelectResourceStep extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
const { sortedColumnKey } = this.props;
this.state = { this.state = {
isInitialized: false,
count: null, count: null,
error: false, error: false,
page: 1,
page_size: 5,
resources: [], resources: [],
sortOrder: 'ascending',
sortedColumnKey
}; };
this.handleSetPage = this.handleSetPage.bind(this); this.qsConfig = getQSConfig('resource', {
this.handleSort = this.handleSort.bind(this); page: 1,
this.readResourceList = this.readResourceList.bind(this); page_size: 5,
order_by: props.sortedColumnKey,
});
} }
componentDidMount () { componentDidMount () {
const { page_size, page, sortedColumnKey } = this.state; this.readResourceList();
this.readResourceList({ page_size, page, order_by: sortedColumnKey });
} }
handleSetPage (pageNumber) { componentDidUpdate (prevProps) {
const { page_size, sortedColumnKey, sortOrder } = this.state; const { location } = this.props;
const page = parseInt(pageNumber, 10); if (location !== prevProps.location) {
this.readResourceList();
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
} }
this.readResourceList({ page_size, page, order_by });
} }
handleSort (sortedColumnKey, sortOrder) { async readResourceList () {
const { page_size } = this.state; const { onSearch, location } = this.props;
const queryParams = parseNamespacedQueryString(this.qsConfig, location.search);
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 });
this.setState({
isLoading: true,
error: false,
});
try { try {
const { data } = await onSearch(queryParams); const { data } = await onSearch(queryParams);
const { count, results } = data; const { count, results } = data;
const stateToUpdate = { this.setState({
count,
page,
resources: results, resources: results,
sortOrder, count,
sortedColumnKey isInitialized: true,
}; isLoading: false,
error: false,
this.setState(stateToUpdate); });
} catch (err) { } catch (err) {
this.setState({ error: true }); this.setState({
isLoading: false,
error: true,
});
} }
} }
render () { render () {
const { const {
isInitialized,
isLoading,
count, count,
error, error,
page,
page_size,
resources, resources,
sortOrder,
sortedColumnKey
} = this.state; } = this.state;
const { const {
columns, columns,
displayKey, displayKey,
emptyListBody,
emptyListTitle,
onRowClick, onRowClick,
selectedLabel, selectedLabel,
selectedResourceRows selectedResourceRows,
itemName,
} = this.props; } = this.props;
return ( return (
<Fragment> <Fragment>
<Fragment> {isLoading && (<div>Loading...</div>)}
{(resources.length === 0) ? ( {isInitialized && (
<EmptyState> <Fragment>
<EmptyStateIcon icon={CubesIcon} /> {selectedResourceRows.length > 0 && (
<Title size="lg"> <SelectedList
{emptyListTitle} displayKey={displayKey}
</Title> label={selectedLabel}
<EmptyStateBody> onRemove={onRowClick}
{emptyListBody} selected={selectedResourceRows}
</EmptyStateBody> showOverflowAfter={5}
</EmptyState> />
) : ( )}
<Fragment> <PaginatedDataList
{selectedResourceRows.length > 0 && ( items={resources}
<SelectedList itemCount={count}
displayKey={displayKey} itemName={itemName}
label={selectedLabel} qsConfig={this.qsConfig}
onRemove={onRowClick} toolbarColumns={
selected={selectedResourceRows} columns
showOverflowAfter={5} }
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
onSelect={() => onRowClick(item)}
/> />
)} )}
<DataListToolbar alignToolbarLeft
columns={columns} showPageSizeOptions={false}
noLeftMargin paginationStyling={paginationStyling}
onSearch={this.onSearch} />
handleSort={this.handleSort} </Fragment>
sortOrder={sortOrder} )}
sortedColumnKey={sortedColumnKey}
/>
<DataList aria-label={i18nMark('Roles List')}>
{resources.map(i => (
<CheckboxListItem
isSelected={selectedResourceRows.some(item => item.id === i.id)}
itemId={i.id}
key={i.id}
name={i[displayKey]}
onSelect={() => onRowClick(i)}
/>
))}
</DataList>
<Pagination
count={count}
onSetPage={this.handleSetPage}
page={page}
pageCount={Math.ceil(count / page_size)}
pageSizeOptions={null}
page_size={page_size}
showPageSizeOptions={false}
style={paginationStyling}
/>
</Fragment>
)}
</Fragment>
{ error ? <div>error</div> : '' } { error ? <div>error</div> : '' }
</Fragment> </Fragment>
); );
@@ -193,23 +135,22 @@ class SelectResourceStep extends React.Component {
SelectResourceStep.propTypes = { SelectResourceStep.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
displayKey: PropTypes.string, displayKey: PropTypes.string,
emptyListBody: PropTypes.string,
emptyListTitle: PropTypes.string,
onRowClick: PropTypes.func, onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired, onSearch: PropTypes.func.isRequired,
selectedLabel: PropTypes.string, selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object), selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
sortedColumnKey: PropTypes.string sortedColumnKey: PropTypes.string,
itemName: PropTypes.string,
}; };
SelectResourceStep.defaultProps = { SelectResourceStep.defaultProps = {
displayKey: 'name', displayKey: 'name',
emptyListBody: i18nMark('Please add items to populate this list'),
emptyListTitle: i18nMark('No Items Found'),
onRowClick: () => {}, onRowClick: () => {},
selectedLabel: i18nMark('Selected Items'), selectedLabel: i18nMark('Selected Items'),
selectedResourceRows: [], selectedResourceRows: [],
sortedColumnKey: 'name' sortedColumnKey: 'name',
itemName: 'item',
}; };
export default SelectResourceStep; export { SelectResourceStep as _SelectResourceStep };
export default withRouter(SelectResourceStep);

View File

@@ -1,26 +1,22 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; 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 { import {
Button, Button,
ButtonVariant, ButtonVariant,
Chip, Chip,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
InputGroup, InputGroup,
Modal, Modal,
Title
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { Trans, t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withNetwork } from '../../contexts/Network'; import { withNetwork } from '../../contexts/Network';
import PaginatedDataList from '../PaginatedDataList';
import CheckboxListItem from '../ListItem'; import CheckboxListItem from '../ListItem';
import DataListToolbar from '../DataListToolbar';
import SelectedList from '../SelectedList'; import SelectedList from '../SelectedList';
import Pagination from '../Pagination'; import { getQSConfig, parseNamespacedQueryString } from '../../util/qs';
const paginationStyling = { const paginationStyling = {
paddingLeft: '0', paddingLeft: '0',
@@ -39,71 +35,48 @@ class Lookup extends React.Component {
lookupSelectedItems: [...props.value] || [], lookupSelectedItems: [...props.value] || [],
results: [], results: [],
count: 0, count: 0,
error: null,
};
this.qsConfig = getQSConfig('lookup', {
page: 1, page: 1,
page_size: 5, page_size: 5,
error: null, order_by: props.sortedColumnKey,
sortOrder: 'ascending', });
sortedColumnKey: props.sortedColumnKey
};
this.onSetPage = this.onSetPage.bind(this);
this.handleModalToggle = this.handleModalToggle.bind(this); this.handleModalToggle = this.handleModalToggle.bind(this);
this.toggleSelected = this.toggleSelected.bind(this); this.toggleSelected = this.toggleSelected.bind(this);
this.saveModal = this.saveModal.bind(this); this.saveModal = this.saveModal.bind(this);
this.getData = this.getData.bind(this); this.getData = this.getData.bind(this);
this.onSearch = this.onSearch.bind(this);
this.onSort = this.onSort.bind(this);
} }
componentDidMount () { componentDidMount () {
const { page_size, page } = this.state; this.getData();
this.getData({ page_size, page });
} }
onSearch () { componentDidUpdate (prevProps) {
const { sortedColumnKey, sortOrder } = this.state; const { location } = this.props;
this.onSort(sortedColumnKey, sortOrder); if (location !== prevProps.location) {
} this.getData();
}
onSort (sortedColumnKey, sortOrder) {
this.setState({ page: 1, sortedColumnKey, sortOrder }, this.getData);
} }
async getData () { async getData () {
const { getItems, handleHttpError } = this.props; const { getItems, handleHttpError, location } = this.props;
const { page, page_size, sortedColumnKey, sortOrder } = this.state; const queryParams = parseNamespacedQueryString(this.qsConfig, location.search);
this.setState({ error: false }); this.setState({ error: false });
const queryParams = {
page,
page_size
};
if (sortedColumnKey) {
queryParams.order_by = sortOrder === 'descending' ? `-${sortedColumnKey}` : sortedColumnKey;
}
try { try {
const { data } = await getItems(queryParams); const { data } = await getItems(queryParams);
const { results, count } = data; const { results, count } = data;
const stateToUpdate = { this.setState({
results, results,
count count
}; });
this.setState(stateToUpdate);
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true }); 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) { toggleSelected (row) {
const { name, onLookupSave } = this.props; const { name, onLookupSave } = this.props;
const { lookupSelectedItems: updatedSelectedItems, isModalOpen } = this.state; const { lookupSelectedItems: updatedSelectedItems, isModalOpen } = this.state;
@@ -156,10 +129,6 @@ class Lookup extends React.Component {
error, error,
results, results,
count, count,
page,
page_size,
sortedColumnKey,
sortOrder
} = this.state; } = this.state;
const { id, lookupHeader = 'items', value, columns } = this.props; const { id, lookupHeader = 'items', value, columns } = this.props;
@@ -200,49 +169,25 @@ class Lookup extends React.Component {
<Button key="cancel" variant="secondary" onClick={this.handleModalToggle}>{(results.length === 0) ? i18n._(t`Close`) : i18n._(t`Cancel`)}</Button> <Button key="cancel" variant="secondary" onClick={this.handleModalToggle}>{(results.length === 0) ? i18n._(t`Close`) : i18n._(t`Cancel`)}</Button>
]} ]}
> >
{(results.length === 0) ? ( <PaginatedDataList
<EmptyState> items={results}
<EmptyStateIcon icon={CubesIcon} /> itemCount={count}
<Title size="lg"> itemName={lookupHeader}
<Trans>{`No ${lookupHeader} Found`}</Trans> qsConfig={this.qsConfig}
</Title> toolbarColumns={columns}
<EmptyStateBody> renderItem={item => (
<Trans>{`Please add ${lookupHeader.toLowerCase()} to populate this list`}</Trans> <CheckboxListItem
</EmptyStateBody> key={item.id}
</EmptyState> itemId={item.id}
) : ( name={item.name}
<Fragment> isSelected={lookupSelectedItems.some(i => i.id === item.id)}
<DataListToolbar onSelect={() => this.toggleSelected(item)}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={columns}
onSearch={this.onSearch}
onSort={this.onSort}
noLeftMargin
/> />
<ul className="pf-c-data-list awx-c-list"> )}
{results.map(i => ( alignToolbarLeft
<CheckboxListItem showPageSizeOptions={false}
key={i.id} paginationStyling={paginationStyling}
itemId={i.id} />
name={i.name}
isSelected={lookupSelectedItems.some(item => item.id === i.id)}
onSelect={() => this.toggleSelected(i)}
/>
))}
</ul>
<Pagination
count={count}
page={page}
pageCount={Math.ceil(count / page_size)}
page_size={page_size}
onSetPage={this.onSetPage}
pageSizeOptions={null}
showPageSizeOptions={false}
style={paginationStyling}
/>
</Fragment>
)}
{lookupSelectedItems.length > 0 && ( {lookupSelectedItems.length > 0 && (
<SelectedList <SelectedList
label={i18n._(t`Selected`)} label={i18n._(t`Selected`)}
@@ -264,9 +209,10 @@ Lookup.propTypes = {
id: PropTypes.string, id: PropTypes.string,
getItems: PropTypes.func.isRequired, getItems: PropTypes.func.isRequired,
lookupHeader: PropTypes.string, lookupHeader: PropTypes.string,
name: PropTypes.string, name: PropTypes.string, // TODO: delete, unused ?
onLookupSave: PropTypes.func.isRequired, onLookupSave: PropTypes.func.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired, value: PropTypes.arrayOf(PropTypes.object).isRequired,
sortedColumnKey: PropTypes.string.isRequired,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
@@ -276,4 +222,4 @@ Lookup.defaultProps = {
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };
export default withNetwork(Lookup); export default withNetwork(withRouter(Lookup));

View File

@@ -20,8 +20,12 @@ import { withRouter, Link } from 'react-router-dom';
import Pagination from '../Pagination'; import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import { encodeQueryString, parseQueryString } from '../../util/qs'; import {
parseNamespacedQueryString,
updateNamespacedQueryString,
} from '../../util/qs';
import { pluralize, getArticle, ucFirst } from '../../util/strings'; import { pluralize, getArticle, ucFirst } from '../../util/strings';
import { QSConfig } from '../../types';
const detailWrapperStyle = { const detailWrapperStyle = {
display: 'grid', display: 'grid',
@@ -47,12 +51,14 @@ class PaginatedDataList extends React.Component {
} }
getPageCount () { getPageCount () {
const { itemCount, queryParams: { page_size } } = this.props; const { itemCount, qsConfig, location } = this.props;
return Math.ceil(itemCount / page_size); const queryParams = parseNamespacedQueryString(qsConfig, location.search);
return Math.ceil(itemCount / queryParams.page_size);
} }
getSortOrder () { getSortOrder () {
const { queryParams } = this.props; const { qsConfig, location } = this.props;
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) { if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return [queryParams.order_by.substr(1), 'descending']; return [queryParams.order_by.substr(1), 'descending'];
} }
@@ -74,13 +80,9 @@ class PaginatedDataList extends React.Component {
} }
pushHistoryState (newParams) { pushHistoryState (newParams) {
const { history } = this.props; const { history, qsConfig } = this.props;
const { pathname, search } = history.location; const { pathname, search } = history.location;
const currentParams = parseQueryString(search); const qs = updateNamespacedQueryString(qsConfig, search, newParams);
const qs = encodeQueryString({
...currentParams,
...newParams
});
history.push(`${pathname}?${qs}`); history.push(`${pathname}?${qs}`);
} }
@@ -93,7 +95,7 @@ class PaginatedDataList extends React.Component {
const { const {
items, items,
itemCount, itemCount,
queryParams, qsConfig,
renderItem, renderItem,
toolbarColumns, toolbarColumns,
additionalControls, additionalControls,
@@ -102,9 +104,14 @@ class PaginatedDataList extends React.Component {
showSelectAll, showSelectAll,
isAllSelected, isAllSelected,
onSelectAll, onSelectAll,
alignToolbarLeft,
showPageSizeOptions,
paginationStyling,
location,
} = this.props; } = this.props;
const { error } = this.state; const { error } = this.state;
const [orderBy, sortOrder] = this.getSortOrder(); const [orderBy, sortOrder] = this.getSortOrder();
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
return ( return (
<I18n> <I18n>
{({ i18n }) => ( {({ i18n }) => (
@@ -153,6 +160,7 @@ class PaginatedDataList extends React.Component {
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={onSelectAll} onSelectAll={onSelectAll}
additionalControls={additionalControls} additionalControls={additionalControls}
noLeftMargin={alignToolbarLeft}
/> />
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}> <DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}>
{items.map(item => (renderItem ? renderItem(item) : ( {items.map(item => (renderItem ? renderItem(item) : (
@@ -182,10 +190,12 @@ class PaginatedDataList extends React.Component {
</DataList> </DataList>
<Pagination <Pagination
count={itemCount} count={itemCount}
page={queryParams.page} page={queryParams.page || 1}
pageCount={this.getPageCount()} pageCount={this.getPageCount()}
page_size={queryParams.page_size} page_size={queryParams.page_size}
onSetPage={this.handleSetPage} onSetPage={this.handleSetPage}
showPageSizeOptions={showPageSizeOptions}
style={paginationStyling}
/> />
</Fragment> </Fragment>
)} )}
@@ -202,19 +212,12 @@ const Item = PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
}); });
const QueryParams = PropTypes.shape({
page: PropTypes.number,
page_size: PropTypes.number,
order_by: PropTypes.string,
});
PaginatedDataList.propTypes = { PaginatedDataList.propTypes = {
items: PropTypes.arrayOf(Item).isRequired, items: PropTypes.arrayOf(Item).isRequired,
itemCount: PropTypes.number.isRequired, itemCount: PropTypes.number.isRequired,
itemName: PropTypes.string, itemName: PropTypes.string,
itemNamePlural: PropTypes.string, itemNamePlural: PropTypes.string,
// TODO: determine this internally but pass in defaults? qsConfig: QSConfig.isRequired,
queryParams: QueryParams.isRequired,
renderItem: PropTypes.func, renderItem: PropTypes.func,
toolbarColumns: arrayOf(shape({ toolbarColumns: arrayOf(shape({
name: string.isRequired, name: string.isRequired,
@@ -225,6 +228,9 @@ PaginatedDataList.propTypes = {
showSelectAll: PropTypes.bool, showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool, isAllSelected: PropTypes.bool,
onSelectAll: PropTypes.func, onSelectAll: PropTypes.func,
alignToolbarLeft: PropTypes.bool,
showPageSizeOptions: PropTypes.bool,
paginationStyling: PropTypes.shape(),
}; };
PaginatedDataList.defaultProps = { PaginatedDataList.defaultProps = {
@@ -238,6 +244,9 @@ PaginatedDataList.defaultProps = {
showSelectAll: false, showSelectAll: false,
isAllSelected: false, isAllSelected: false,
onSelectAll: null, onSelectAll: null,
alignToolbarLeft: false,
showPageSizeOptions: true,
paginationStyling: null,
}; };
export { PaginatedDataList as _PaginatedDataList }; export { PaginatedDataList as _PaginatedDataList };

View File

@@ -6,14 +6,14 @@ import OrganizationAccessItem from '../../components/OrganizationAccessItem';
import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal'; import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal';
import AddResourceRole from '../../../../components/AddRole/AddResourceRole'; import AddResourceRole from '../../../../components/AddRole/AddResourceRole';
import { withNetwork } from '../../../../contexts/Network'; import { withNetwork } from '../../../../contexts/Network';
import { parseQueryString } from '../../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import { Organization } from '../../../../types'; import { Organization } from '../../../../types';
const DEFAULT_QUERY_PARAMS = { const QS_CONFIG = getQSConfig('access', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'first_name', order_by: 'first_name',
}; });
class OrganizationAccess extends React.Component { class OrganizationAccess extends React.Component {
static propTypes = { static propTypes = {
@@ -54,12 +54,12 @@ class OrganizationAccess extends React.Component {
} }
async readOrgAccessList () { async readOrgAccessList () {
const { organization, api, handleHttpError } = this.props; const { organization, api, handleHttpError, location } = this.props;
this.setState({ isLoading: true }); this.setState({ isLoading: true });
try { try {
const { data } = await api.getOrganizationAccessList( const { data } = await api.getOrganizationAccessList(
organization.id, organization.id,
this.getQueryParams() parseNamespacedQueryString(QS_CONFIG, location.search)
); );
this.setState({ this.setState({
itemCount: data.count || 0, 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) { confirmRemoveRole (role, accessRecord) {
this.setState({ this.setState({
roleToDelete: role, roleToDelete: role,
@@ -175,7 +165,7 @@ class OrganizationAccess extends React.Component {
items={accessRecords} items={accessRecords}
itemCount={itemCount} itemCount={itemCount}
itemName="role" itemName="role"
queryParams={this.getQueryParams()} qsConfig={QS_CONFIG}
toolbarColumns={[ toolbarColumns={[
{ name: i18nMark('Name'), key: 'first_name', isSortable: true }, { name: i18nMark('Name'), key: 'first_name', isSortable: true },
{ name: i18nMark('Username'), key: 'username', isSortable: true }, { name: i18nMark('Username'), key: 'username', isSortable: true },

View File

@@ -4,13 +4,13 @@ import { withRouter } from 'react-router-dom';
import { withNetwork } from '../../../../contexts/Network'; import { withNetwork } from '../../../../contexts/Network';
import PaginatedDataList from '../../../../components/PaginatedDataList'; import PaginatedDataList from '../../../../components/PaginatedDataList';
import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem'; 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: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}; });
const COLUMNS = [ const COLUMNS = [
{ key: 'name', name: 'Name', isSortable: true }, { 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 () { async readNotifications () {
const { api, handleHttpError, id } = this.props; const { id, api, handleHttpError, location } = this.props;
const params = this.getQueryParams(); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ isLoading: true }); this.setState({ isLoading: true });
try { try {
const { data } = await api.getOrganizationNotifications(id, params); const { data } = await api.getOrganizationNotifications(id, params);
@@ -191,7 +181,7 @@ class OrganizationNotifications extends Component {
items={notifications} items={notifications}
itemCount={itemCount} itemCount={itemCount}
itemName="notification" itemName="notification"
queryParams={this.getQueryParams()} qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS} toolbarColumns={COLUMNS}
renderItem={(notification) => ( renderItem={(notification) => (
<NotificationListItem <NotificationListItem

View File

@@ -2,14 +2,14 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import PaginatedDataList from '../../../../components/PaginatedDataList'; import PaginatedDataList from '../../../../components/PaginatedDataList';
import { parseQueryString } from '../../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network'; import { withNetwork } from '../../../../contexts/Network';
const DEFAULT_QUERY_PARAMS = { const QS_CONFIG = getQSConfig('team', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}; });
class OrganizationTeams extends React.Component { class OrganizationTeams extends React.Component {
constructor (props) { constructor (props) {
@@ -37,19 +37,9 @@ class OrganizationTeams extends React.Component {
} }
} }
getQueryParams () {
const { location } = this.props;
const searchParams = parseQueryString(location.search.substring(1));
return {
...DEFAULT_QUERY_PARAMS,
...searchParams,
};
}
async readOrganizationTeamsList () { async readOrganizationTeamsList () {
const { api, handleHttpError, id } = this.props; const { id, api, handleHttpError, location } = this.props;
const params = this.getQueryParams(); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ isLoading: true, error: null }); this.setState({ isLoading: true, error: null });
try { try {
const { const {
@@ -86,7 +76,7 @@ class OrganizationTeams extends React.Component {
items={teams} items={teams}
itemCount={itemCount} itemCount={itemCount}
itemName="team" itemName="team"
queryParams={this.getQueryParams()} qsConfig={QS_CONFIG}
/> />
)} )}
</Fragment> </Fragment>

View File

@@ -13,7 +13,7 @@ import PaginatedDataList, {
ToolbarAddButton ToolbarAddButton
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import OrganizationListItem from '../components/OrganizationListItem'; import OrganizationListItem from '../components/OrganizationListItem';
import { encodeQueryString, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
const COLUMNS = [ const COLUMNS = [
{ name: i18nMark('Name'), key: 'name', isSortable: true }, { name: i18nMark('Name'), key: 'name', isSortable: true },
@@ -21,11 +21,11 @@ const COLUMNS = [
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true }, { name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
]; ];
const DEFAULT_QUERY_PARAMS = { const QS_CONFIG = getQSConfig('organization', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}; });
class OrganizationsList extends Component { class OrganizationsList extends Component {
constructor (props) { constructor (props) {
@@ -39,10 +39,8 @@ class OrganizationsList extends Component {
selected: [], selected: [],
}; };
this.getQueryParams = this.getQueryParams.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this); this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this); this.handleSelect = this.handleSelect.bind(this);
this.updateUrl = this.updateUrl.bind(this);
this.fetchOptionsOrganizations = this.fetchOptionsOrganizations.bind(this); this.fetchOptionsOrganizations = this.fetchOptionsOrganizations.bind(this);
this.fetchOrganizations = this.fetchOrganizations.bind(this); this.fetchOrganizations = this.fetchOrganizations.bind(this);
this.handleOrgDelete = this.handleOrgDelete.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 () { async handleOrgDelete () {
const { selected } = this.state; const { selected } = this.state;
const { api, handleHttpError } = this.props; const { api, handleHttpError } = this.props;
@@ -101,25 +89,14 @@ class OrganizationsList extends Component {
errorHandled = handleHttpError(err); errorHandled = handleHttpError(err);
} finally { } finally {
if (!errorHandled) { if (!errorHandled) {
const queryParams = this.getQueryParams(); this.fetchOrganizations();
this.fetchOrganizations(queryParams);
} }
} }
} }
updateUrl (queryParams) {
const { history, location } = this.props;
const pathname = '/organizations';
const search = `?${encodeQueryString(queryParams)}`;
if (search !== location.search) {
history.replace({ pathname, search });
}
}
async fetchOrganizations () { async fetchOrganizations () {
const { api, handleHttpError } = this.props; const { api, handleHttpError, location } = this.props;
const params = this.getQueryParams(); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ error: false, isLoading: true }); this.setState({ error: false, isLoading: true });
@@ -185,7 +162,7 @@ class OrganizationsList extends Component {
items={organizations} items={organizations}
itemCount={itemCount} itemCount={itemCount}
itemName="organization" itemName="organization"
queryParams={this.getQueryParams()} qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS} toolbarColumns={COLUMNS}
showSelectAll showSelectAll
isAllSelected={isAllSelected} isAllSelected={isAllSelected}

View File

@@ -47,3 +47,9 @@ export const Organization = shape({
created: string, created: string,
modified: string, modified: string,
}); });
export const QSConfig = shape({
defaultParams: shape().isRequired,
namespace: string,
integerFields: arrayOf(string).isRequired,
});

View File

@@ -40,3 +40,74 @@ export const parseQueryString = (queryString, integerFields = ['page', 'page_siz
return Object.assign(...keyValuePairs.map(([k, v]) => ({ [k]: v }))); 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}`);
}