diff --git a/__tests__/api.test.js b/__tests__/api.test.js
index 9bc5a7f218..b05e8fdcbd 100644
--- a/__tests__/api.test.js
+++ b/__tests__/api.test.js
@@ -1,4 +1,3 @@
-
import mockAxios from 'axios';
import APIClient from '../src/api';
import * as endpoints from '../src/endpoints';
diff --git a/__tests__/components/DataListToolbar.test.jsx b/__tests__/components/DataListToolbar.test.jsx
new file mode 100644
index 0000000000..22b91e6530
--- /dev/null
+++ b/__tests__/components/DataListToolbar.test.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import DataListToolbar from '../../src/components/DataListToolbar';
+
+describe('', () => {
+ const columns = [{ name: 'Name', key: 'name', isSortable: true }];
+ const noop = () => {};
+
+ let toolbar;
+
+ afterEach(() => {
+ if (toolbar) {
+ toolbar.unmount();
+ toolbar = null;
+ }
+ });
+
+ test('it triggers the expected callbacks', () => {
+ const search = 'button[aria-label="Search"]';
+ const searchTextInput = 'input[aria-label="search text input"]';
+ const selectAll = 'input[aria-label="Select all"]';
+ const sort = 'button[aria-label="Sort"]';
+
+ const onSearch = jest.fn();
+ const onSort = jest.fn();
+ const onSelectAll = jest.fn();
+
+ toolbar = mount(
+
+ );
+
+ toolbar.find(sort).simulate('click');
+ toolbar.find(selectAll).simulate('change', { target: { checked: false } });
+
+ expect(onSelectAll).toHaveBeenCalledTimes(1);
+ expect(onSort).toHaveBeenCalledTimes(1);
+ expect(onSort).toBeCalledWith('name', 'descending');
+
+ expect(onSelectAll).toHaveBeenCalledTimes(1);
+ expect(onSelectAll.mock.calls[0][0]).toBe(false);
+
+ toolbar.find(searchTextInput).instance().value = 'test-321';
+ toolbar.find(searchTextInput).simulate('change');
+ toolbar.find(search).simulate('click');
+
+ expect(onSearch).toHaveBeenCalledTimes(1);
+ expect(onSearch).toBeCalledWith('test-321');
+ });
+});
diff --git a/__tests__/components/Pagination.test.jsx b/__tests__/components/Pagination.test.jsx
new file mode 100644
index 0000000000..7158c04471
--- /dev/null
+++ b/__tests__/components/Pagination.test.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Pagination from '../../src/components/Pagination';
+
+describe('', () => {
+ const noop = () => {};
+
+ let pagination;
+
+ afterEach(() => {
+ if (toolbar) {
+ pagination.unmount();
+ pagination = null;
+ }
+ });
+
+ test('it triggers the expected callbacks on next and last', () => {
+ const next = 'button[aria-label="next"]';
+ const last = 'button[aria-label="last"]';
+
+ const onSetPage = jest.fn();
+
+ pagination = mount(
+
+ );
+
+ pagination.find(next).simulate('click');
+
+ expect(onSetPage).toHaveBeenCalledTimes(1);
+ expect(onSetPage).toBeCalledWith(2, 5);
+
+ pagination.find(last).simulate('click');
+
+ expect(onSetPage).toHaveBeenCalledTimes(2);
+ expect(onSetPage).toBeCalledWith(5, 5);
+ });
+
+ test('it triggers the expected callback on previous and first', () => {
+ const previous = 'button[aria-label="previous"]';
+ const first = 'button[aria-label="first"]';
+
+ const onSetPage = jest.fn();
+
+ pagination = mount(
+
+ );
+
+ pagination.find(previous).simulate('click');
+
+ expect(onSetPage).toHaveBeenCalledTimes(1);
+ expect(onSetPage).toBeCalledWith(4, 5);
+
+ pagination.find(first).simulate('click');
+
+ expect(onSetPage).toHaveBeenCalledTimes(2);
+ expect(onSetPage).toBeCalledWith(1, 5);
+ });
+});
diff --git a/__tests__/pages/Organizations.jsx b/__tests__/pages/Organizations.jsx
index 87efbf95f1..fdbc219cc8 100644
--- a/__tests__/pages/Organizations.jsx
+++ b/__tests__/pages/Organizations.jsx
@@ -1,40 +1,66 @@
import React from 'react';
+import { HashRouter } from 'react-router-dom';
+
import { mount } from 'enzyme';
+
+import api from '../../src/api';
import { API_ORGANIZATIONS } from '../../src/endpoints';
import Organizations from '../../src/pages/Organizations';
describe('', () => {
let pageWrapper;
- let pageSections;
- let title;
- let gallery;
- let galleryItems;
- let orgCards;
- const orgs = [
- { id: 1, name: 'org 1' },
- { id: 2, name: 'org 2' },
- { id: 3, name: 'org 3' }
+ const results = [
+ {
+ id: 1,
+ name: 'org 1',
+ summary_fields: {
+ related_field_counts: {
+ users: 1,
+ teams: 1,
+ admins: 1
+ }
+ }
+ },
+ {
+ id: 2,
+ name: 'org 2',
+ summary_fields: {
+ related_field_counts: {
+ users: 1,
+ teams: 1,
+ admins: 1
+ }
+ }
+ },
+ {
+ id: 3,
+ name: 'org 3',
+ summary_fields: {
+ related_field_counts: {
+ users: 1,
+ teams: 1,
+ admins: 1
+ }
+ }
+ },
];
-
- const findOrgCards = () => {
- galleryItems = pageWrapper.find('GalleryItem');
- orgCards = pageWrapper.find('OrganizationCard');
- };
+ const count = results.length;
+ const response = { data: { count, results } };
beforeEach(() => {
- pageWrapper = mount();
- pageSections = pageWrapper.find('PageSection');
- title = pageWrapper.find('Title');
- gallery = pageWrapper.find('Gallery');
- findOrgCards();
+ api.get = jest.fn().mockImplementation(() => Promise.resolve(response));
+ pageWrapper = mount();
});
afterEach(() => {
pageWrapper.unmount();
});
- test('initially renders without crashing', () => {
+ test('it renders expected content', () => {
+ const pageSections = pageWrapper.find('PageSection');
+ const title = pageWrapper.find('Title');
+
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
@@ -42,19 +68,23 @@ describe('', () => {
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
- expect(gallery.length).toBe(1);
- // by default, no galleryItems or orgCards
- expect(galleryItems.length).toBe(0);
- expect(orgCards.length).toBe(0);
- // will render all orgCards if state is set
- pageWrapper.setState({ organizations: orgs });
- findOrgCards();
- expect(galleryItems.length).toBe(3);
- expect(orgCards.length).toBe(3);
+ expect(pageWrapper.find('ul').length).toBe(1);
+ expect(pageWrapper.find('ul li').length).toBe(0);
+ // will render all list items on update
+ pageWrapper.update();
+ expect(pageWrapper.find('ul li').length).toBe(count);
});
test('API Organization endpoint is valid', () => {
expect(API_ORGANIZATIONS).toBeDefined();
});
+ test('it displays a tooltip on delete hover', () => {
+ const tooltip = '.pf-c-tooltip__content';
+ const deleteButton = 'button[aria-label="Delete"]';
+
+ expect(pageWrapper.find(tooltip).length).toBe(0);
+ pageWrapper.find(deleteButton).simulate('mouseover');
+ expect(pageWrapper.find(tooltip).length).toBe(1);
+ });
});
diff --git a/__tests__/qs.test.js b/__tests__/qs.test.js
new file mode 100644
index 0000000000..d2f4c92985
--- /dev/null
+++ b/__tests__/qs.test.js
@@ -0,0 +1,29 @@
+import { encodeQueryString, parseQueryString } from '../src/qs';
+
+describe('qs (qs.js)', () => {
+ test('encodeQueryString returns the expected queryString', () => {
+ [
+ [null, ''],
+ [{}, ''],
+ [{ order_by: 'name', page: 1, page_size: 5 }, 'order_by=name&page=1&page_size=5'],
+ [{ '-order_by': 'name', page: '1', page_size: 5 }, '-order_by=name&page=1&page_size=5'],
+ ]
+ .forEach(([params, expectedQueryString]) => {
+ const actualQueryString = encodeQueryString(params);
+
+ expect(actualQueryString).toEqual(expectedQueryString);
+ });
+ });
+
+ 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)
+ });
+ });
+});
diff --git a/src/api.js b/src/api.js
index 1650c5d8df..0976d9f9bc 100644
--- a/src/api.js
+++ b/src/api.js
@@ -44,7 +44,7 @@ class APIClient {
await this.http.post(endpoints.API_LOGIN, data, { headers });
}
- get = (endpoint) => this.http.get(endpoint);
+ get = (endpoint, params = {}) => this.http.get(endpoint, { params });
}
export default new APIClient();
diff --git a/src/app.scss b/src/app.scss
index d31a962175..15cc031b34 100644
--- a/src/app.scss
+++ b/src/app.scss
@@ -59,4 +59,54 @@
.pf-l-page__main-section.pf-m-condensed {
padding-top: 16px;
padding-bottom: 16px;
-}
\ No newline at end of file
+}
+
+//
+// toolbar overrides
+//
+
+.pf-l-toolbar {
+ align-self: center;
+ height: 60px;
+}
+
+
+//
+// data list overrides
+//
+
+.pf-c-data-list {
+ --pf-global--target-size--MinHeight: 32px;
+ --pf-global--target-size--MinWidth: 32px;
+ --pf-global--FontSize--md: 14px;
+
+ --pf-c-data-list__item--PaddingTop: 16px;
+ --pf-c-data-list__item--PaddingBottom: 16px;
+}
+
+.pf-c-data-list__item {
+ --pf-c-data-list__item--PaddingLeft: 20px;
+ --pf-c-data-list__item--PaddingRight: 0px;
+}
+
+.pf-c-data-list__check {
+ --pf-c-data-list__check--MarginRight: 0;
+}
+
+.pf-c-data-list__check:after {
+ content: "";
+ background-color: #d7d7d7;
+ width: 1px;
+ height: 25px;
+ display: block;
+ margin-left: 20px;
+ margin-right: 20px;
+}
+
+.pf-c-data-list__cell a {
+ margin-right: 8px;
+}
+
+.pf-c-data-list__cell span {
+ margin-right: 18px;
+}
diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx
new file mode 100644
index 0000000000..a3fbb80d92
--- /dev/null
+++ b/src/components/DataListToolbar/DataListToolbar.jsx
@@ -0,0 +1,226 @@
+import React from 'react';
+import {
+ Button,
+ Checkbox,
+ Dropdown,
+ DropdownPosition,
+ DropdownToggle,
+ DropdownItem,
+ FormGroup,
+ KebabToggle,
+ Level,
+ LevelItem,
+ TextInput,
+ Toolbar,
+ ToolbarGroup,
+ ToolbarItem,
+} from '@patternfly/react-core';
+import {
+ BarsIcon,
+ EqualsIcon,
+ SortAlphaDownIcon,
+ SortAlphaUpIcon,
+ SortNumericDownIcon,
+ SortNumericUpIcon,
+ TrashAltIcon,
+} from '@patternfly/react-icons';
+
+import Tooltip from '../Tooltip';
+
+class DataListToolbar extends React.Component {
+ constructor(props) {
+ super(props);
+
+ const { sortedColumnKey } = this.props;
+
+ this.state = {
+ isSearchDropdownOpen: false,
+ isSortDropdownOpen: false,
+ searchKey: sortedColumnKey,
+ searchValue: '',
+ };
+ }
+
+ handleSearchInputChange = searchValue => {
+ this.setState({ searchValue });
+ };
+
+ onSortDropdownToggle = isSortDropdownOpen => {
+ this.setState({ isSortDropdownOpen });
+ };
+
+ onSortDropdownSelect = ({ target }) => {
+ const { columns, onSort, sortOrder } = this.props;
+
+ const [{ key }] = columns.filter(({ name }) => name === target.innerText);
+
+ this.setState({ isSortDropdownOpen: false });
+
+ onSort(key, sortOrder);
+ };
+
+ onSearchDropdownToggle = isSearchDropdownOpen => {
+ this.setState({ isSearchDropdownOpen });
+ };
+
+ onSearchDropdownSelect = ({ target }) => {
+ const { columns } = this.props;
+
+ const targetName = target.innerText;
+ const [{ key }] = columns.filter(({ name }) => name === targetName);
+
+ this.setState({ isSearchDropdownOpen: false, searchKey: key });
+ };
+
+ onActionToggle = isActionDropdownOpen => {
+ this.setState({ isActionDropdownOpen });
+ };
+
+ onActionSelect = ({ target }) => {
+ this.setState({ isActionDropdownOpen: false });
+ };
+
+ render() {
+ const { up } = DropdownPosition;
+ const {
+ columns,
+ isAllSelected,
+ onSearch,
+ onSelectAll,
+ onSort,
+ sortedColumnKey,
+ sortOrder,
+ } = this.props;
+ const {
+ isActionDropdownOpen,
+ isSearchDropdownOpen,
+ isSortDropdownOpen,
+ searchKey,
+ searchValue,
+ } = this.state;
+
+ const [searchColumn] = columns
+ .filter(({ key }) => key === searchKey);
+ const searchColumnName = searchColumn.name;
+
+ const [sortedColumn] = columns
+ .filter(({ key }) => key === sortedColumnKey);
+ const sortedColumnName = sortedColumn.name;
+ const isSortNumeric = sortedColumn.isNumeric;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { searchColumnName }
+
+ )}>
+ {columns.filter(({ key }) => key !== searchKey).map(({ key, name }) => (
+
+ { name }
+
+ ))}
+
+
+
+
+
+
+
+
+
+ { sortedColumnName }
+
+ )}>
+ {columns
+ .filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey)
+ .map(({ key, name }) => (
+
+ { name }
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default DataListToolbar;
\ No newline at end of file
diff --git a/src/components/DataListToolbar/index.js b/src/components/DataListToolbar/index.js
new file mode 100644
index 0000000000..e1f0a45dc8
--- /dev/null
+++ b/src/components/DataListToolbar/index.js
@@ -0,0 +1,3 @@
+import DataListToolbar from './DataListToolbar';
+
+export default DataListToolbar;
diff --git a/src/components/DataListToolbar/styles.scss b/src/components/DataListToolbar/styles.scss
new file mode 100644
index 0000000000..66fd6f0902
--- /dev/null
+++ b/src/components/DataListToolbar/styles.scss
@@ -0,0 +1,89 @@
+.awx-toolbar {
+ --awx-toolbar--BackgroundColor: var(--pf-global--BackgroundColor--light-100);
+ --awx-toolbar--BorderColor: var(--pf-global--Color--light-200);
+ --awx-toolbar--BorderWidth: var(--pf-global--BorderWidth--sm);
+
+ border: var(--awx-toolbar--BorderWidth) solid var(--awx-toolbar--BorderColor);
+ background-color: var(--awx-toolbar--BackgroundColor);
+ height: 70px;
+ padding-top: 5px;
+
+ --pf-global--target-size--MinHeight: 0px;
+ --pf-global--target-size--MinWidth: 0px;
+ --pf-global--FontSize--md: 14px;
+}
+
+.awx-toolbar .pf-c-button.pf-m-plain {
+ --pf-c-button--m-plain--PaddingLeft: 0px;
+ --pf-c-button--m-plain--PaddingRight: 0px;
+}
+
+.awx-toolbar .pf-l-toolbar__group {
+ --pf-l-toolbar__group--MarginRight: 0px;
+ --pf-l-toolbar__group--MarginLeft: 0px;
+}
+
+.awx-toolbar .pf-l-toolbar__group:after {
+ content: "";
+ background-color: #d7d7d7;
+ width: 1px;
+ height: 30px;
+ display: block;
+ margin-left: 20px;
+ margin-right: 20px;
+}
+
+.awx-toolbar button {
+ height: 30px;
+ padding: 0px;
+}
+
+.awx-toolbar .pf-c-input-group {
+ min-height: 0px;
+ height: 30px;
+
+ input {
+ padding: 0px;
+ width: 300px;
+ }
+
+ .pf-m-tertiary {
+ width: 34px;
+ padding: 0px;
+ }
+}
+
+.awx-toolbar .pf-c-dropdown__toggle {
+ min-height: 30px;
+ min-width: 70px;
+ height: 30px;
+ padding: 0px;
+ margin: 0px;
+
+ .pf-c-dropdown__toggle-icon {
+ margin: 0px;
+ padding-top: 3px;
+ padding-left: 3px;
+ }
+}
+
+.awx-toolbar .pf-l-toolbar__item + .pf-l-toolbar__item button {
+ margin-left: 20px;
+}
+
+.awx-toolbar .pf-c-button.pf-m-primary {
+ background-color: #5cb85c;
+ min-width: 0px;
+ width: 58px;
+ height: 30px;
+ text-align: center;
+
+ padding: 0px;
+ margin: 0px;
+ margin-right: 20px;
+ margin-left: 20px;
+}
+
+.awx-toolbar .pf-l-toolbar__item .pf-c-button.pf-m-plain {
+ font-size: 18px;
+}
diff --git a/src/components/OrganizationCard.jsx b/src/components/OrganizationCard.jsx
deleted file mode 100644
index 3703b60456..0000000000
--- a/src/components/OrganizationCard.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React, { Component } from 'react';
-import {
- Card,
- CardHeader,
- CardBody,
-} from '@patternfly/react-core';
-
-class OrganizationCard extends Component {
- static title = 'Organization Card';
-
- constructor (props) {
- super(props);
-
- const { name } = props.organization;
-
- this.state = { name };
- }
-
- render () {
- const { name } = this.state;
-
- return (
-
- {name}
- Card Body
-
- );
- }
-}
-
-export default OrganizationCard;
diff --git a/src/components/OrganizationListItem.jsx b/src/components/OrganizationListItem.jsx
new file mode 100644
index 0000000000..f64beb3984
--- /dev/null
+++ b/src/components/OrganizationListItem.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {
+ Badge,
+ Checkbox,
+} from '@patternfly/react-core';
+
+export default ({ itemId, name, userCount, teamCount, adminCount, isSelected, onSelect }) => (
+
+
+
+
+
+
+
Users
+
+ {' '}
+ {userCount}
+ {' '}
+
+
Teams
+
+ {' '}
+ {teamCount}
+ {' '}
+
+
Admins
+
+ {' '}
+ {adminCount}
+ {' '}
+
+
+
+
+);
diff --git a/src/components/Pagination/Pagination.jsx b/src/components/Pagination/Pagination.jsx
new file mode 100644
index 0000000000..7daf81091f
--- /dev/null
+++ b/src/components/Pagination/Pagination.jsx
@@ -0,0 +1,220 @@
+import React, { Component } from 'react';
+import {
+ Button,
+ Dropdown,
+ DropdownDirection,
+ DropdownItem,
+ DropdownToggle,
+ Form,
+ FormGroup,
+ Level,
+ LevelItem,
+ TextInput,
+ Toolbar,
+ ToolbarGroup,
+ ToolbarItem,
+ Split,
+ SplitItem,
+} from '@patternfly/react-core';
+
+class Pagination extends Component {
+ constructor (props) {
+ super(props);
+
+ const { page } = this.props;
+
+ this.state = { value: page, isOpen: false };
+ }
+
+ componentDidUpdate (prevProps) {
+ const { page } = this.props;
+
+ if (prevProps.page !== page) {
+ this.setState({ value: page });
+ }
+ }
+
+ onPageChange = value => {
+ this.setState({ value });
+ };
+
+ onSubmit = event => {
+ const { onSetPage, page, pageCount, page_size } = this.props;
+ const { value } = this.state;
+
+ event.preventDefault();
+
+ const isPositiveInteger = value >>> 0 === parseFloat(value) && parseInt(value, 10) > 0;
+ const isValid = isPositiveInteger && parseInt(value, 10) <= pageCount;
+
+ if (isValid) {
+ onSetPage(value, page_size);
+ } else{
+ this.setState({ value: page });
+ }
+ };
+
+ onFirst = () => {
+ const { onSetPage, page_size} = this.props;
+
+ onSetPage(1, page_size);
+ };
+
+ onPrevious = () => {
+ const { onSetPage, page, page_size } = this.props;
+ const previousPage = page - 1;
+
+ if (previousPage >= 1) {
+ onSetPage(previousPage, page_size)
+ }
+ };
+
+ onNext = () => {
+ const { onSetPage, page, pageCount, page_size } = this.props;
+ const nextPage = page + 1;
+
+ if (nextPage <= pageCount) {
+ onSetPage(nextPage, page_size)
+ }
+ };
+
+ onLast = () => {
+ const { onSetPage, pageCount, page_size } = this.props;
+
+ onSetPage(pageCount, page_size)
+ };
+
+ onTogglePageSize = isOpen => {
+ this.setState({ isOpen });
+ };
+
+ onSelectPageSize = ({ target }) => {
+ const { onSetPage } = this.props;
+
+ const page = 1;
+ const page_size = parseInt(target.innerText, 10);
+
+ this.setState({ isOpen: false });
+
+ onSetPage(page, page_size);
+ };
+
+ render () {
+ const { up } = DropdownDirection;
+ const {
+ count,
+ page,
+ pageCount,
+ page_size,
+ pageSizeOptions,
+ } = this.props;
+ const { value, isOpen } = this.state;
+
+ const opts = pageSizeOptions.slice().reverse().filter(o => o !== page_size);
+ const isOnFirst = page === 1;
+ const isOnLast = page === pageCount;
+
+ const itemCount = isOnLast ? count % page_size : page_size;
+ const itemMin = ((page - 1) * page_size) + 1;
+ const itemMax = itemMin + itemCount - 1;
+
+ const disabledStyle = {
+ backgroundColor: '#EDEDED',
+ border: '1px solid #C2C2CA',
+ borderRadius: '0px',
+ color: '#C2C2CA',
+ };
+
+ return (
+
+
+
+
+ { page_size }
+
+ )}>
+ {opts.map(option => (
+
+ { option }
+
+ ))}
+ Per Page
+
+
+
+ {itemMin} - {itemMax } of { count }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Pagination;
diff --git a/src/components/Pagination/index.js b/src/components/Pagination/index.js
new file mode 100644
index 0000000000..9ed530b1f6
--- /dev/null
+++ b/src/components/Pagination/index.js
@@ -0,0 +1,3 @@
+import Pagination from './Pagination';
+
+export default Pagination;
diff --git a/src/components/Pagination/styles.scss b/src/components/Pagination/styles.scss
new file mode 100644
index 0000000000..3374ad45b6
--- /dev/null
+++ b/src/components/Pagination/styles.scss
@@ -0,0 +1,39 @@
+.awx-pagination {
+ --awx-pagination--BackgroundColor: var(--pf-global--BackgroundColor--light-100);
+ --awx-pagination--BorderColor: var(--pf-global--Color--light-200);
+ --awx-pagination--BorderWidth: var(--pf-global--BorderWidth--sm);
+
+ border: var(--awx-pagination--BorderWidth) solid var(--awx-pagination--BorderColor);
+ background-color: var(--awx-pagination--BackgroundColor);
+ padding-left: 20px;
+ padding-right: 20px;
+ padding-top: 20px;
+ height: 70px;
+
+ --pf-global--target-size--MinHeight: 30px;
+ --pf-global--target-size--MinWidth: 30px;
+ --pf-global--FontSize--md: 14px;
+
+ .pf-c-input-group button {
+ width: 30px;
+ height: 30px;
+ padding: 0px;
+ }
+
+ .pf-c-dropdown button {
+ width: 55px;
+ height: 30px;
+ padding-left: 10px;
+ padding-right: 10px;
+ margin: 0px;
+ margin-right: 10px;
+ text-align: left;
+
+ .pf-c-dropdown__toggle-icon {
+ margin: 0px;
+ margin-top: 2px;
+ padding: 0px;
+ float: right;
+ }
+ }
+}
diff --git a/src/components/Tooltip/Tooltip.jsx b/src/components/Tooltip/Tooltip.jsx
new file mode 100644
index 0000000000..f4a3617dff
--- /dev/null
+++ b/src/components/Tooltip/Tooltip.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+
+class Tooltip extends React.Component {
+ transforms = {
+ top: {
+ bottom: "100%",
+ left: "50%",
+ transform: "translate(-50%, -25%)"
+ },
+ bottom: {
+ top: "100%",
+ left: "50%",
+ transform: "translate(-50%, 25%)"
+ },
+ left: {
+ top: "50%",
+ right: "100%",
+ transform: "translate(-25%, -50%)"
+ },
+ right: {
+ bottom: "100%",
+ left: "50%",
+ transform: "translate(25%, 50%)"
+ },
+ };
+
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ isDisplayed: false
+ };
+ }
+
+ render() {
+ const {
+ children,
+ message,
+ position,
+ } = this.props;
+ const {
+ isDisplayed
+ } = this.state;
+
+ return (
+ this.setState({ isDisplayed: false })}>
+ { isDisplayed &&
+
+ }
+ this.setState({ isDisplayed: true })}>
+ { children }
+
+
+ )
+ }
+}
+
+export default Tooltip;
diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js
new file mode 100644
index 0000000000..d8851cb0a0
--- /dev/null
+++ b/src/components/Tooltip/index.js
@@ -0,0 +1,3 @@
+import Tooltip from './Tooltip';
+
+export default Tooltip;
diff --git a/src/index.jsx b/src/index.jsx
index d20574471f..cd653d3c53 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -9,6 +9,8 @@ import '@patternfly/react-core/dist/styles/base.css';
import '@patternfly/patternfly-next/patternfly.css';
import './app.scss';
+import './components/Pagination/styles.scss';
+import './components/DataListToolbar/styles.scss';
const el = document.getElementById('app');
diff --git a/src/pages/Organizations.jsx b/src/pages/Organizations.jsx
index 2a332bced1..050031afeb 100644
--- a/src/pages/Organizations.jsx
+++ b/src/pages/Organizations.jsx
@@ -1,38 +1,190 @@
-import React, { Component, Fragment } from 'react';
+import React, {
+ Component,
+ Fragment
+} from 'react';
+import {
+ withRouter
+} from 'react-router-dom';
import {
- Gallery,
- GalleryItem,
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
-import OrganizationCard from '../components/OrganizationCard';
+import DataListToolbar from '../components/DataListToolbar';
+import OrganizationListItem from '../components/OrganizationListItem';
+import Pagination from '../components/Pagination';
+
import api from '../api';
import { API_ORGANIZATIONS } from '../endpoints';
+import {
+ encodeQueryString,
+ parseQueryString,
+} from '../qs';
+
class Organizations extends Component {
+ columns = [
+ { name: 'Name', key: 'name', isSortable: true },
+ { name: 'Modified', key: 'modified', isSortable: true, isNumeric: true },
+ { name: 'Created', key: 'created', isSortable: true, isNumeric: true },
+ ];
+
+ defaultParams = {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+ };
+
+ pageSizeOptions = [5, 10, 25, 50];
+
constructor (props) {
super(props);
+ const { page, page_size } = this.getQueryParams();
+
this.state = {
- organizations: [],
- error: false,
+ page,
+ page_size,
+ sortedColumnKey: 'name',
+ sortOrder: 'ascending',
+ count: null,
+ error: null,
+ loading: true,
+ results: [],
+ selected: [],
};
}
- async componentDidMount () {
+ componentDidMount () {
+ const queryParams = this.getQueryParams();
+
+ this.fetchOrganizations(queryParams);
+ }
+
+ onSearch () {
+ const { sortedColumnKey, sortOrder } = this.state;
+
+ this.onSort(sortedColumnKey, sortOrder);
+ }
+
+ getQueryParams (overrides = {}) {
+ const { location } = this.props;
+ const { search } = location;
+
+ const searchParams = parseQueryString(search.substring(1));
+
+ return Object.assign({}, this.defaultParams, searchParams, overrides);
+ }
+
+ onSort = (sortedColumnKey, sortOrder) => {
+ const { page_size } = this.state;
+
+ let order_by = sortedColumnKey;
+
+ if (sortOrder === 'descending') {
+ order_by = `-${order_by}`;
+ }
+
+ const queryParams = this.getQueryParams({ order_by, page_size });
+
+ this.fetchOrganizations(queryParams);
+ };
+
+ onSetPage = (pageNumber, pageSize) => {
+ const page = parseInt(pageNumber, 10);
+ const page_size = parseInt(pageSize, 10);
+
+ const queryParams = this.getQueryParams({ page, page_size });
+
+ this.fetchOrganizations(queryParams);
+ };
+
+ onSelectAll = isSelected => {
+ const { results } = this.state;
+
+ const selected = isSelected ? results.map(o => o.id) : [];
+
+ this.setState({ selected });
+ };
+
+ onSelect = id => {
+ const { selected } = this.state;
+
+ const isSelected = selected.includes(id);
+
+ if (isSelected) {
+ this.setState({ selected: selected.filter(s => s !== id) });
+ } else {
+ this.setState({ selected: selected.concat(id) });
+ }
+ };
+
+ updateUrl (queryParams) {
+ const { history, location } = this.props;
+
+ const pathname = '/organizations';
+ const search = `?${encodeQueryString(queryParams)}`;
+
+ if (search !== location.search) {
+ history.replace({ pathname, search });
+ }
+ }
+
+ async fetchOrganizations (queryParams) {
+ const { page, page_size, 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, loading: true });
+
try {
- const { data } = await api.get(API_ORGANIZATIONS);
- this.setState({ organizations: data.results });
+ const { data } = await api.get(API_ORGANIZATIONS, queryParams);
+ const { count, results } = data;
+
+ const pageCount = Math.ceil(count / page_size);
+
+ this.setState({
+ count,
+ page,
+ pageCount,
+ page_size,
+ sortOrder,
+ sortedColumnKey,
+ results,
+ selected: [],
+ });
+ this.updateUrl(queryParams);
} catch (err) {
- this.setState({ error: err });
+ this.setState({ error: true });
+ } finally {
+ this.setState({ loading: false });
}
}
render () {
- const { light, medium } = PageSectionVariants;
- const { organizations, error } = this.state;
+ const {
+ light,
+ medium,
+ } = PageSectionVariants;
+ const {
+ count,
+ error,
+ loading,
+ page,
+ pageCount,
+ page_size,
+ sortedColumnKey,
+ sortOrder,
+ results,
+ selected,
+ } = this.state;
return (
@@ -40,18 +192,43 @@ class Organizations extends Component {
Organizations
-
- {organizations.map(o => (
-
-
-
+
+
+ { results.map(o => (
+ this.onSelect(o.id)}
+ />
))}
- { error ? error
: '' }
-
+
+
+ { loading ? loading...
: '' }
+ { error ? error
: '' }
);
}
}
-export default Organizations;
+export default withRouter(Organizations);
diff --git a/src/qs.js b/src/qs.js
new file mode 100644
index 0000000000..380ca1d321
--- /dev/null
+++ b/src/qs.js
@@ -0,0 +1,41 @@
+/**
+ * Convert query param object to url query string
+ *
+ * @param {object} query param object
+ * @return {string} url query string
+ */
+export const encodeQueryString = (params) => {
+ if (!params) {
+ return '';
+ }
+
+ return Object.keys(params)
+ .sort()
+ .map(key => ([key, params[key]]))
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ .join('&');
+};
+
+/**
+ * Convert url query string to query param object
+ *
+ * @param {string} url query string
+ * @param {object} default params
+ * @param {array} array of keys to parse as integers
+ * @return {object} query param object
+ */
+export const parseQueryString = (queryString, integerFields = ['page', 'page_size']) => {
+ if (!queryString) return {};
+
+ const keyValuePairs = queryString.split('&')
+ .map(s => s.split('='))
+ .map(([key, value]) => {
+ if (integerFields.includes(key)) {
+ return [key, parseInt(value, 10)];
+ }
+
+ return [key, value];
+ });
+
+ return Object.assign(...keyValuePairs.map(([k, v]) => ({ [k]: v })));
+};