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 }) => ( +
  • +
    + +
    +
    + + { name } + +
    +
    + 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 } + +
    + + +
    +
    + +
    + Page of { pageCount } + +
    + +
    + + +
    +
    +
    +
    +
    +
    + ); + } +} + +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 && +
    +
    +
    + { message } +
    +
    + } + 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 }))); +};