Merge pull request #5 from ansible/pf4-table

basic pf4 data list, toolbar, pagination
This commit is contained in:
Jake McDermott 2018-11-19 11:35:28 -05:00 committed by GitHub
commit 7fdf27eece
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1204 additions and 83 deletions

View File

@ -1,4 +1,3 @@
import mockAxios from 'axios';
import APIClient from '../src/api';
import * as endpoints from '../src/endpoints';

View File

@ -0,0 +1,57 @@
import React from 'react';
import { mount } from 'enzyme';
import DataListToolbar from '../../src/components/DataListToolbar';
describe('<DataListToolbar />', () => {
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(
<DataListToolbar
isAllSelected={false}
sortedColumnKey="name"
sortOrder="ascending"
columns={columns}
onSearch={onSearch}
onSort={onSort}
onSelectAll={onSelectAll}
/>
);
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');
});
});

View File

@ -0,0 +1,72 @@
import React from 'react';
import { mount } from 'enzyme';
import Pagination from '../../src/components/Pagination';
describe('<Pagination />', () => {
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
count={21}
page={1}
pageCount={5}
page_size={5}
pageSizeOptions={[5, 10, 25, 50]}
onSetPage={onSetPage}
/>
);
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
count={21}
page={5}
pageCount={5}
page_size={5}
pageSizeOptions={[5, 10, 25, 50]}
onSetPage={onSetPage}
/>
);
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);
});
});

View File

@ -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('<Organizations />', () => {
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(<Organizations />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
gallery = pageWrapper.find('Gallery');
findOrgCards();
api.get = jest.fn().mockImplementation(() => Promise.resolve(response));
pageWrapper = mount(<HashRouter><Organizations /></HashRouter>);
});
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('<Organizations />', () => {
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);
});
});

29
__tests__/qs.test.js Normal file
View File

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

View File

@ -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();

View File

@ -59,4 +59,54 @@
.pf-l-page__main-section.pf-m-condensed {
padding-top: 16px;
padding-bottom: 16px;
}
}
//
// 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;
}

View File

@ -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 (
<div className="awx-toolbar">
<Level>
<LevelItem>
<Toolbar style={{ marginLeft: "20px" }}>
<ToolbarGroup>
<ToolbarItem>
<Checkbox
checked={isAllSelected}
onChange={onSelectAll}
aria-label="Select all"
id="select-all"/>
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem>
<div className="pf-c-input-group">
<Dropdown
onToggle={this.onSearchDropdownToggle}
onSelect={this.onSearchDropdownSelect}
direction={up}
isOpen={isSearchDropdownOpen}
toggle={(
<DropdownToggle
onToggle={this.onSearchDropdownToggle}>
{ searchColumnName }
</DropdownToggle>
)}>
{columns.filter(({ key }) => key !== searchKey).map(({ key, name }) => (
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
))}
</Dropdown>
<TextInput
type="search"
aria-label="search text input"
value={searchValue}
onChange={this.handleSearchInputChange}/>
<Button
variant="tertiary"
aria-label="Search"
onClick={() => onSearch(searchValue)}>
<i className="fas fa-search" aria-hidden="true"></i>
</Button>
</div>
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem>
<Dropdown
onToggle={this.onSortDropdownToggle}
onSelect={this.onSortDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={(
<DropdownToggle
onToggle={this.onSortDropdownToggle}>
{ sortedColumnName }
</DropdownToggle>
)}>
{columns
.filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
))}
</Dropdown>
</ToolbarItem>
<ToolbarItem>
<Button
onClick={() => onSort(sortedColumnKey, sortOrder === "ascending" ? "descending" : "ascending")}
variant="plain"
aria-label="Sort">
{
isSortNumeric ? (
sortOrder === "ascending" ? <SortNumericUpIcon /> : <SortNumericDownIcon />
) : (
sortOrder === "ascending" ? <SortAlphaUpIcon /> : <SortAlphaDownIcon />
)
}
</Button>
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem>
<Button variant="plain" aria-label="Expand">
<BarsIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button variant="plain" aria-label="Collapse">
<EqualsIcon />
</Button>
</ToolbarItem>
</ToolbarGroup>
</Toolbar>
</LevelItem>
<LevelItem>
<Tooltip message="Delete" position="top">
<Button variant="plain" aria-label="Delete">
<TrashAltIcon/>
</Button>
</Tooltip>
<Button variant="primary" aria-label="Add">
Add
</Button>
</LevelItem>
</Level>
</div>
);
}
}
export default DataListToolbar;

View File

@ -0,0 +1,3 @@
import DataListToolbar from './DataListToolbar';
export default DataListToolbar;

View File

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

View File

@ -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 (
<Card>
<CardHeader>{name}</CardHeader>
<CardBody>Card Body</CardBody>
</Card>
);
}
}
export default OrganizationCard;

View File

@ -0,0 +1,44 @@
import React from 'react';
import {
Badge,
Checkbox,
} from '@patternfly/react-core';
export default ({ itemId, name, userCount, teamCount, adminCount, isSelected, onSelect }) => (
<li key={itemId} className="pf-c-data-list__item" aria-labelledby="check-action-item1">
<div className="pf-c-data-list__check">
<Checkbox
checked={isSelected}
onChange={onSelect}
aria-label={`select organization ${itemId}`}
id={`select-organization-${itemId}`}
/>
</div>
<div className="pf-c-data-list__cell">
<span id="check-action-item1">
<a href={`#/organizations/${itemId}`}>{ name }</a>
</span>
</div>
<div className="pf-c-data-list__cell">
<a href="#/dashboard"> Users </a>
<Badge isRead>
{' '}
{userCount}
{' '}
</Badge>
<a href="#/dashboard"> Teams </a>
<Badge isRead>
{' '}
{teamCount}
{' '}
</Badge>
<a href="#/dashboard"> Admins </a>
<Badge isRead>
{' '}
{adminCount}
{' '}
</Badge>
</div>
<div className="pf-c-data-list__cell" />
</li>
);

View File

@ -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 (
<div className="awx-pagination">
<Level>
<LevelItem>
<Dropdown
onToggle={this.onTogglePageSize}
onSelect={this.onSelectPageSize}
direction={up}
isOpen={isOpen}
toggle={(
<DropdownToggle
onToggle={this.onTogglePageSize}>
{ page_size }
</DropdownToggle>
)}>
{opts.map(option => (
<DropdownItem key={option} component="button">
{ option }
</DropdownItem>
))}
</Dropdown> Per Page
</LevelItem>
<LevelItem>
<Split gutter="md" className="pf-u-display-flex pf-u-align-items-center">
<SplitItem>{itemMin} - {itemMax } of { count }</SplitItem>
<SplitItem>
<div className="pf-c-input-group">
<Button
variant="tertiary"
aria-label="first"
style={isOnFirst ? disabledStyle : {}}
isDisabled={isOnFirst}
onClick={this.onFirst}>
<i className="fas fa-angle-double-left"></i>
</Button>
<Button
variant="tertiary"
aria-label="previous"
style={isOnFirst ? disabledStyle : {}}
isDisabled={isOnFirst}
onClick={this.onPrevious}>
<i className="fas fa-angle-left"></i>
</Button>
</div>
</SplitItem>
<SplitItem isMain>
<form onSubmit={this.onSubmit}>
Page <TextInput
isDisabled={pageCount === 1}
aria-label="Page Number"
style={{
height: '30px',
width: '30px',
textAlign: 'center',
padding: '0',
margin: '0',
...(pageCount === 1 ? disabledStyle : {})
}}
value={value}
type="text"
onChange={this.onPageChange}
/> of { pageCount }
</form>
</SplitItem>
<SplitItem>
<div className="pf-c-input-group">
<Button
variant="tertiary"
aria-label="next"
style={isOnLast ? disabledStyle : {}}
isDisabled={isOnLast}
onClick={this.onNext}>
<i className="fas fa-angle-right"></i>
</Button>
<Button
variant="tertiary"
aria-label="last"
style={isOnLast ? disabledStyle : {}}
isDisabled={isOnLast}
onClick={this.onLast}>
<i className="fas fa-angle-double-right"></i>
</Button>
</div>
</SplitItem>
</Split>
</LevelItem>
</Level>
</div>
);
}
}
export default Pagination;

View File

@ -0,0 +1,3 @@
import Pagination from './Pagination';
export default Pagination;

View File

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

View File

@ -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 (
<span
style={{ position: "relative"}}
onMouseLeave={() => this.setState({ isDisplayed: false })}>
{ isDisplayed &&
<div
style={{ position: "absolute", zIndex: "10", ...this.transforms[position] }}
className={`pf-c-tooltip pf-m-${position}`}>
<div className="pf-c-tooltip__arrow"></div>
<div className="pf-c-tooltip__content">
{ message }
</div>
</div>
}
<span
onMouseOver={() => this.setState({ isDisplayed: true })}>
{ children }
</span>
</span>
)
}
}
export default Tooltip;

View File

@ -0,0 +1,3 @@
import Tooltip from './Tooltip';
export default Tooltip;

View File

@ -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');

View File

@ -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 (
<Fragment>
@ -40,18 +192,43 @@ class Organizations extends Component {
<Title size="2xl">Organizations</Title>
</PageSection>
<PageSection variant={medium}>
<Gallery gutter="md">
{organizations.map(o => (
<GalleryItem key={o.id}>
<OrganizationCard key={o.id} organization={o} />
</GalleryItem>
<DataListToolbar
isAllSelected={selected.length === results.length}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={this.onSearch}
onSort={this.onSort}
onSelectAll={this.onSelectAll}
/>
<ul className="pf-c-data-list" aria-label="Organizations List">
{ results.map(o => (
<OrganizationListItem
key={o.id}
itemId={o.id}
name={o.name}
userCount={o.summary_fields.related_field_counts.users}
teamCount={o.summary_fields.related_field_counts.teams}
adminCount={o.summary_fields.related_field_counts.admins}
isSelected={selected.includes(o.id)}
onSelect={() => this.onSelect(o.id)}
/>
))}
{ error ? <div>error</div> : '' }
</Gallery>
</ul>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
pageSizeOptions={this.pageSizeOptions}
onSetPage={this.onSetPage}
/>
{ loading ? <div>loading...</div> : '' }
{ error ? <div>error</div> : '' }
</PageSection>
</Fragment>
);
}
}
export default Organizations;
export default withRouter(Organizations);

41
src/qs.js Normal file
View File

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