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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 2656 additions and 2648 deletions

View File

@ -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('<Lookup />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<_Lookup
lookup_header="Foo Bar"
<Lookup
lookupHeader="Foo Bar"
name="fooBar"
value={mockData}
onLookupSave={() => { }}
@ -25,8 +26,8 @@ describe('<Lookup />', () => {
test('API response is formatted properly', (done) => {
const wrapper = mountWithContexts(
<_Lookup
lookup_header="Foo Bar"
<Lookup
lookupHeader="Foo Bar"
name="fooBar"
value={mockData}
onLookupSave={() => { }}
@ -47,9 +48,9 @@ describe('<Lookup />', () => {
const spy = jest.spyOn(_Lookup.prototype, 'handleModalToggle');
const mockSelected = [{ name: 'foo', id: 1 }];
const wrapper = mountWithContexts(
<_Lookup
<Lookup
id="search"
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
name="fooBar"
value={mockSelected}
onLookupSave={() => { }}
@ -74,17 +75,22 @@ describe('<Lookup />', () => {
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
<Lookup
id="search"
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
name="fooBar"
value={mockSelected}
onLookupSave={() => { }}
getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })}
getItems={() => ({ data })}
columns={mockColumns}
sortedColumnKey="name"
handleHttpError={() => {}}
/>
);
setImmediate(() => {
@ -99,17 +105,22 @@ describe('<Lookup />', () => {
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
<Lookup
id="search"
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
name="fooBar"
value={mockData}
onLookupSave={() => { }}
getItems={() => { }}
getItems={() => ({ data })}
columns={mockColumns}
sortedColumnKey="name"
handleHttpError={() => {}}
/>
);
const removeIcon = wrapper.find('button[aria-label="close"]').first();
@ -121,7 +132,7 @@ describe('<Lookup />', () => {
mockData = [{ name: 'foo', id: 0 }, { name: 'bar', id: 1 }];
const wrapper = mountWithContexts(
<Lookup
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
onLookupSave={() => { }}
value={mockData}
selected={[]}
@ -138,7 +149,7 @@ describe('<Lookup />', () => {
mockData = [];
const wrapper = mountWithContexts(
<Lookup
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
onLookupSave={() => { }}
value={mockData}
getItems={() => { }}
@ -166,11 +177,12 @@ describe('<Lookup />', () => {
const onLookupSaveFn = jest.fn();
const wrapper = mountWithContexts(
<Lookup
lookup_header="Foo Bar"
lookupHeader="Foo Bar"
name="fooBar"
value={mockData}
onLookupSave={onLookupSaveFn}
getItems={() => { }}
sortedColumnKey="name"
/>
).find('Lookup');
wrapper.instance().toggleSelected({
@ -188,61 +200,31 @@ describe('<Lookup />', () => {
}], '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);
});
});

View File

@ -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('<PaginatedDataList />', () => {
afterEach(() => {
jest.restoreAllMocks();
@ -27,11 +33,11 @@ describe('<PaginatedDataList />', () => {
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('<PaginatedDataList />', () => {
page_size: 5,
order_by: 'name',
}}
qsConfig={qsConfig}
/>, { context: { router: { history } } }
);
@ -52,7 +59,7 @@ describe('<PaginatedDataList />', () => {
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('<PaginatedDataList />', () => {
// 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('<PaginatedDataList />', () => {
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');
});
});

View File

@ -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('<SelectResourceStep />', () => {
const columns = [
@ -21,13 +23,14 @@ describe('<SelectResourceStep />', () => {
/>
);
});
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('<SelectResourceStep />', () => {
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(
<SelectResourceStep
<_SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
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(
<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', () => {
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(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => {}}
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]);
});
});

View File

@ -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 = (
<Provider
value={context[key]}
i18n={isFirst ? defaultContexts.linguiPublisher.i18n : null}
>
{wrapped}
</Provider>
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 (
<RootDialogProvider value={dialog}>
<_NetworkProvider value={network}>
<ConfigProvider
value={config}
i18n={defaultContexts.linguiPublisher.i18n}
>
{component}
</ConfigProvider>
</_NetworkProvider>
</RootDialogProvider>
);
isFirst = false;
}
});
return wrapped;
}
return (
<Wrap>{node}</Wrap>
);
}
function applyDefaultContexts (context) {

View File

@ -191,4 +191,19 @@ describe('mountWithContexts', () => {
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 { 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('<OrganizationTeams />', () => {
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(
<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',
expect(list.prop('qsConfig')).toEqual({
namespace: 'team',
defaultParams: {
page: 1,
page_size: 5,
order_by: 'name',
},
integerFields: ['page', 'page_size'],
});
});
});

View File

@ -146,24 +146,6 @@ describe('<OrganizationsList />', () => {
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 () => {
const history = createMemoryHistory({
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)', () => {
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');
});
});
});

View File

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

View File

@ -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 (
<Fragment>
<Fragment>
{(resources.length === 0) ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
{emptyListTitle}
</Title>
<EmptyStateBody>
{emptyListBody}
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
showOverflowAfter={5}
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<Fragment>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
showOverflowAfter={5}
/>
)}
<PaginatedDataList
items={resources}
itemCount={count}
itemName={itemName}
qsConfig={this.qsConfig}
toolbarColumns={
columns
}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
onSelect={() => onRowClick(item)}
/>
)}
<DataListToolbar
columns={columns}
noLeftMargin
onSearch={this.onSearch}
handleSort={this.handleSort}
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>
alignToolbarLeft
showPageSizeOptions={false}
paginationStyling={paginationStyling}
/>
</Fragment>
)}
{ error ? <div>error</div> : '' }
</Fragment>
);
@ -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);

View File

@ -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 {
<Button key="cancel" variant="secondary" onClick={this.handleModalToggle}>{(results.length === 0) ? i18n._(t`Close`) : i18n._(t`Cancel`)}</Button>
]}
>
{(results.length === 0) ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>{`No ${lookupHeader} Found`}</Trans>
</Title>
<EmptyStateBody>
<Trans>{`Please add ${lookupHeader.toLowerCase()} to populate this list`}</Trans>
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
<DataListToolbar
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={columns}
onSearch={this.onSearch}
onSort={this.onSort}
noLeftMargin
<PaginatedDataList
items={results}
itemCount={count}
itemName={lookupHeader}
qsConfig={this.qsConfig}
toolbarColumns={columns}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={item.name}
isSelected={lookupSelectedItems.some(i => i.id === item.id)}
onSelect={() => this.toggleSelected(item)}
/>
<ul className="pf-c-data-list awx-c-list">
{results.map(i => (
<CheckboxListItem
key={i.id}
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>
)}
)}
alignToolbarLeft
showPageSizeOptions={false}
paginationStyling={paginationStyling}
/>
{lookupSelectedItems.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
@ -264,9 +209,10 @@ Lookup.propTypes = {
id: PropTypes.string,
getItems: PropTypes.func.isRequired,
lookupHeader: PropTypes.string,
name: PropTypes.string,
name: PropTypes.string, // TODO: delete, unused ?
onLookupSave: PropTypes.func.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
sortedColumnKey: PropTypes.string.isRequired,
};
Lookup.defaultProps = {
@ -276,4 +222,4 @@ Lookup.defaultProps = {
};
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 DataListToolbar from '../DataListToolbar';
import { encodeQueryString, parseQueryString } from '../../util/qs';
import {
parseNamespacedQueryString,
updateNamespacedQueryString,
} from '../../util/qs';
import { pluralize, getArticle, ucFirst } from '../../util/strings';
import { QSConfig } from '../../types';
const detailWrapperStyle = {
display: 'grid',
@ -47,12 +51,14 @@ class PaginatedDataList extends React.Component {
}
getPageCount () {
const { itemCount, queryParams: { page_size } } = this.props;
return Math.ceil(itemCount / page_size);
const { itemCount, qsConfig, location } = this.props;
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
return Math.ceil(itemCount / queryParams.page_size);
}
getSortOrder () {
const { queryParams } = this.props;
const { qsConfig, location } = this.props;
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return [queryParams.order_by.substr(1), 'descending'];
}
@ -74,13 +80,9 @@ class PaginatedDataList extends React.Component {
}
pushHistoryState (newParams) {
const { history } = this.props;
const { history, qsConfig } = this.props;
const { pathname, search } = history.location;
const currentParams = parseQueryString(search);
const qs = encodeQueryString({
...currentParams,
...newParams
});
const qs = updateNamespacedQueryString(qsConfig, search, newParams);
history.push(`${pathname}?${qs}`);
}
@ -93,7 +95,7 @@ class PaginatedDataList extends React.Component {
const {
items,
itemCount,
queryParams,
qsConfig,
renderItem,
toolbarColumns,
additionalControls,
@ -102,9 +104,14 @@ class PaginatedDataList extends React.Component {
showSelectAll,
isAllSelected,
onSelectAll,
alignToolbarLeft,
showPageSizeOptions,
paginationStyling,
location,
} = this.props;
const { error } = this.state;
const [orderBy, sortOrder] = this.getSortOrder();
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
return (
<I18n>
{({ i18n }) => (
@ -153,6 +160,7 @@ class PaginatedDataList extends React.Component {
isAllSelected={isAllSelected}
onSelectAll={onSelectAll}
additionalControls={additionalControls}
noLeftMargin={alignToolbarLeft}
/>
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}>
{items.map(item => (renderItem ? renderItem(item) : (
@ -182,10 +190,12 @@ class PaginatedDataList extends React.Component {
</DataList>
<Pagination
count={itemCount}
page={queryParams.page}
page={queryParams.page || 1}
pageCount={this.getPageCount()}
page_size={queryParams.page_size}
onSetPage={this.handleSetPage}
showPageSizeOptions={showPageSizeOptions}
style={paginationStyling}
/>
</Fragment>
)}
@ -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 };

View File

@ -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 },

View File

@ -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) => (
<NotificationListItem

View File

@ -2,14 +2,14 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import { parseQueryString } from '../../../../util/qs';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network';
const DEFAULT_QUERY_PARAMS = {
const QS_CONFIG = getQSConfig('team', {
page: 1,
page_size: 5,
order_by: 'name',
};
});
class OrganizationTeams extends React.Component {
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 () {
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, error: null });
try {
const {
@ -86,7 +76,7 @@ class OrganizationTeams extends React.Component {
items={teams}
itemCount={itemCount}
itemName="team"
queryParams={this.getQueryParams()}
qsConfig={QS_CONFIG}
/>
)}
</Fragment>

View File

@ -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}

View File

@ -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,
});

View File

@ -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}`);
}