Merge pull request #5287 from jlmitch5/searchPlanning

update simple search

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-01-13 16:11:20 +00:00 committed by GitHub
commit 200be3297a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1522 additions and 1038 deletions

View File

@ -1,6 +1,31 @@
# Search Iteration 1 Requirements:
# Simple Search
## DONE
## UX Considerations
Historically, the code that powers search in the AngularJS version of the AWX/Tower UI is very complex and prone to bugs. In order to reduce that complexity, we've made some UX desicions to help make the code easier to maintain.
**ALL query params namespaced and in url bar**
This includes lists that aren't necessarily hyperlinked, like lookup lists. The reason behind this is so we can treat the url bar as the source of truth for queries always. Any params that have both a key AND value that is in the defaultParams section of the qs config are stripped out of the search string (see "Encoding for UI vs. API" for more info on this point)
**Django fuzzy search (`?search=`) is not accessible outside of "advanced search"**
In current smart search typing a term with no key utilizes `?search=` i.e. for "foo" tag, `?search=foo` is given. `?search=` looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well). Due to the fact a key will always be present on the left-hand of simple search, it doesn't make sense to use `?search=` as the default.
We may allow passing of `?search=` through our future advanced search interface. Some details that were gathered in planning phases about `?search=` that might be helpful in the future:
- `?search=` tags are OR'd together (union is returned).
- `?search=foo&name=bar` returns items that have a name field of bar (not case insensitive) AND some text field with foo on it
- `?search=foo&search=bar&name=baz` returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name)
- similarly `?related__search=` looks on the static list of "guesses" for models related to the endpoint. The specific fields are not "searched" for `?related__search=`.
- `?related__search=` not currently used in awx ui
**A note on clicking a tag to putting it back into the search bar**
This was brought up as a nice to have when we were discussing our initial implementation of search in the new application. Since there isn't a way we would be able to know if the user created the tag from the simple or advanced search interface, we wouldn't know where to put it back. This breaks our idea of using the query params as the exclusive source of truth, so we've decided against implementing it for now.
## Tasklist
### DONE
- DONE update handleSearch to follow handleSort param
- DONE update qsConfig columns to utilize isSearchable bool (just like isSortable bool)
@ -24,93 +49,233 @@
- DONE add search filter removal test for qs.
- DONE remove button for search tags of duplicate keys are broken, fix that
## TODO later on in 3.6: stuff to be finished for search iteration 1 (I'll card up an issue to tackle this. I plan on doing this after I finished the awx project branch work)
### TODO pre-holiday break
- Update COLUMNS to SORT_COLUMNS and SEARCH_COLUMNS
- Update to using new PF Toolbar component (currently an experimental component)
- Change the right-hand input based on the type of key selected on the left-hand side. In addition to text input, for our MVP we will support:
- number input
- select input (multiple-choice configured from UI or Options)
- Update the following lists to have the following keys:
- currently handleSearch in Search.jsx always appends the `__icontains` post-fix to make the filtering ux expected work right. Once we start adding number-based params we will won't to change this behavior.
- utilize new defaultSearchKey prop instead of relying on sort key
- make access have username as the default key?
- make default params only accept page, page_size and order_by
- support custom order_by being typed in the url bar
- fix up which keys are displayed in the various lists (note this will also require non-string widgetry to the right of the search key dropdown, for integers, dates, etc.)
- fix any spacing issues like collision with action buttons and overall width of the search bar
**Jobs list** (signed off earlier in chat)
- Name (which is also the name of the job template) - search is ?name=jt
- Job ID - search is ?id=13
- Label name - search is ?labels__name=foo
- Job type (dropdown on right with the different types) ?type = job
- Created by (username) - search is ?created_by__username=admin
- Status - search (dropdown on right with different statuses) is ?status=successful
## Lists affected in 3.6 timeframe
Instances of jobs list include:
- Jobs list
- Host completed jobs list
- JT completed jobs list
We should update all places to use consistent handleSearch/handleSort with paginated data list pattern. This shouldn't be too difficult to get hooked up, as the lists all inherit from PaginatedDataList, where search is hooked up. We will need to make sure the queryset config for each list includes the searchable boolean on keys that will need to be searched for.
**Organization list**
- Name - search is ?name=org
- ? Team name (of a team in the org) - search is ?teams__name=ansible
- ? Username (of a user in the org) - search is ?users__username=johndoe
orgs stuff
- org list
- org add/edit instance groups lookup list
- org access list
- org user/teams list in wizard
- org teams list
- org notifications list
jt stuff
- jt list
- jt add/edit inventory, project, credentials, instance groups lookups lists
- jt access list
- jt user/teams list in wizard
- jt notifications list
- jt schedules list
- jt completed jobs list
jobs stuff
- jobs list
Instances of orgs list include:
- Orgs list
- User orgs list
- Lookup on Project
- Lookup on Credential
- Lookup on Inventory
- User access add wizard list
- Team access add wizard list
# Search code details
**Instance Groups list**
- Name - search is ?name=ig
- ? is_containerized boolean choice (doesn't work right now in API but will soon) - search is ?is_containerized=true
- ? credential name - search is ?credentials__name=kubey
## Search component
Instance of instance groups list include:
- Lookup on Org
- Lookup on JT
- Lookup on Inventory
Search is configured using the qsConfig in a similar way to sort. Columns are passed as an array, as defined in the screen where the list is located. You pass a bool isSearchable (an analog to isSortable) to mark that a certain key should show up in the left-hand dropdown of the search bar.
**Users list**
- Username - search is ?username=johndoe
- First Name - search is ?first_name=John
- Last Name - search is ?last_name=Doe
- ? (if not superfluous, would not include on Team users list) Team Name - search is ?teams__name=team_of_john_does (note API issue: User has no field named "teams")
- ? (only for access or permissions list) Role Name - search is ?roles__name=Admin (note API issue: Role has no field "name")
- ? (if not superfluous, would not include on Organization users list) ORg Name - search is ?organizations__name=org_of_jhn_does
If you don't pass any columns, a default of isSearchable true will be added to a name column, which is nearly universally shared throughout the models of awx.
Instance of user lists include:
- User list
- Org user list
- Access list for Org, JT, Project, Credential, Inventory, User and Team
- Access list for JT
- Access list Project
- Access list for Credential
- Access list for Inventory
- Access list for User
- Access list for Team
- Team add users list
- Users list in access wizard (to add new roles for a particular list) for Org
- Users list in access wizard (to add new roles for a particular list) for JT
- Users list in access wizard (to add new roles for a particular list) for Project
- Users list in access wizard (to add new roles for a particular list) for Credential
- Users list in access wizard (to add new roles for a particular list) for Inventory
**Teams list**
- Name - search is ?name=teamname
- ? Username (of a user in the team) - search is ?users__username=johndoe
- ? (if not superfluous, would not include on Organizations teams list) Org Name - search is ?organizations__name=org_of_john_does
Instance of team lists include:
- Team list
- Org team list
- User team list
- Team list in access wizard (to add new roles for a particular list) for Org
- Team list in access wizard (to add new roles for a particular list) for JT
- Team list in access wizard (to add new roles for a particular list) for Project
- Team list in access wizard (to add new roles for a particular list) for Credential
- Team list in access wizard (to add new roles for a particular list) for Inventory
**Credentials list**
- Name
- ? Type (dropdown on right with different types)
- ? Created by (username)
- ? Modified by (username)
Instance of credential lists include:
- Credential list
- Lookup for JT
- Lookup for Project
- User access add wizard list
- Team access add wizard list
**Projects list**
- Name - search is ?name=proj
- ? Type (dropdown on right with different types) - search is scm_type=git
- ? SCM URL - search is ?scm_url=github.com/ansible/test-playbooks
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of project lists include:
- Project list
- Lookup for JT
- User access add wizard list
- Team access add wizard list
**Templates list**
- Name - search is ?name=cleanup
- ? Type (dropdown on right with different types) - search is ?type=playbook_run
- ? Playbook name - search is ?job_template__playbook=debug.yml
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of template lists include:
- Template list
- Project Templates list
**Inventories list**
- Name - search is ?name=inv
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of inventory lists include:
- Inventory list
- Lookup for JT
- User access add wizard list
- Team access add wizard list
**Groups list**
- Name - search is ?name=group_name
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of group lists include:
- Group list
**Hosts list**
- Name - search is ?name=hostname
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of host lists include:
- Host list
**Notifications list**
- Name - search is ?name=notification_template_name
- ? Type (dropdown on right with different types) - search is ?type=slack
- ? Created by (username) - search is ?created_by__username=admin
- ? Modified by (username) - search is ?modified_by__username=admin
Instance of notification lists include:
- Org notification list
- JT notification list
- Project notification list
### TODO backlog
- Change the right-hand input based on the type of key selected on the left-hand side. We will eventually want to support:
- lookup input (selection of particular resources, based on API list endpoints)
- date picker input
- Update the following lists to have the following keys:
- Update all __name and __username related field search-based keys to be type-ahead lookup based searches
## Code Details
### Search component
The component looks like this:
```
<Search
qsConfig={qsConfig} // used to get namespace (when tags are modified
// they append namespace to query params)
// also used to get "type" of fields (i.e. interger
// fields should get number picker instead of text box)
qsConfig={qsConfig}
columns={columns}
onSearch={onSearch}
/>
```
## ListHeader component
**qsConfig** is used to get namespace so that multiple lists can be on the page. When tags are modified they append namespace to query params. The qsConfig is also used to get "type" of fields in order to correctly parse values as int or date as it is translating.
DataListToolbar, EmptyListControls, and FilterTags components were created/moved to a new sub-component of PaginatedDataList, ListHeader. This allowed us to consolidate the logic between both lists with data (which need to show search, sort, any search tags currently active, and actions) as well as empty lists (which need to show search tags currently active so they can be removed, potentially getting you back to a "list-has-data" state, as well as a subset of options still valid (such as "add").
**columns** are passed as an array, as defined in the screen where the list is located. You pass a bool `isDefault` to indicate that should be the key that shows up in the left-hand dropdown as default in the UI. If you don't pass any columns, a default of `isDefault=true` will be added to a name column, which is nearly universally shared throughout the models of awx.
search and sort are passed callbacks from functions defined in ListHeader. These will be the following.
There is a type attribute that can be `'string'`, `'number'` or `'choice'` (and in the future, `'date'` and `'lookup'`), which will change the type of input on the right-hand side of the search bar. For a key that has a set number of choices, you will pass a choices attribute, which is an array in the format choices: [{label: 'Foo', value: 'foo'}]
```
handleSort (sortedColumnKey, sortOrder) {
this.pushHistoryState({
order_by: sortOrder === 'ascending' ? sortedColumnKey : `-${sortedColumnKey}`,
page: null,
});
}
**onSearch** calls the `mergeParams` qs util in order to add new tags to the queryset. mergeParams is used so that we can support duplicate keys (see mergeParams vs. replaceParams for more info).
handleSearch (key, value, remove) {
this.pushHistoryState({
// ... use key and value to push a new value to the param
// if remove false you add a new tag w key value if remove true,
// you are removing one
});
}
```
### ListHeader component
Similarly, there are handleRemove and handleRemoveAll functions. All of these functions act on the react-router history using the pushHistoryState function. This causes the query params in the url to update, which in turn triggers change handlers that will re-fetch data.
`DataListToolbar`, `EmptyListControls`, and `FilterTags` components were created or moved to a new sub-component of `PaginatedDataList`, `ListHeader`. This allowed us to consolidate the logic between both lists with data (which need to show search, sort, any search tags currently active, and actions) as well as empty lists (which need to show search tags currently active so they can be removed, potentially getting you back to a "list-has-data" state, as well as a subset of options still valid, such as "add").
## FilterTags component
The ability to search and remove filters, as well as sort the list is handled through callbacks which are passed from functions defined in `ListHeader`. These are the following:
Similar to the way the list grabs data based on changes to the react-router params, the FilterTags component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array).
- `handleSort(key, direction)` - use key and direction of sort to change the order_by value in the queryset
- `handleSearch(key, value)` - use key and value to push a new value to the param
- `handleRemove(key, value)` - use key and value to remove a value to the param
- `handleRemoveAll()` - remove all non-default params
Currently the filter tags do not display the key, though that data is available and they could very easily do so.
All of these functions act on the react-router history using the `pushHistoryState` function. This causes the query params in the url to update, which in turn triggers change handlers that will re-fetch data for the lists.
## QS Updates (and supporting duplicate keys)
**a note on sort_columns and search_columns**
The logic that was updated to handle search tags can be found in the qs.js util file.
We have split out column configuration into separate search and sort column array props--these are passed to the search and sort columns. Both accept an isDefault prop for one of the items in the array to be the default option selected when going to the page. Sort column items can pass an isNumeric boolean in order to chnage the iconography of the sort UI element. Search column items can pass type and if applicable choices, in order to configure the right-hand side of the search bar.
From a UX perspective, we wanted to be able to support searching on the same key multiple times (i.e. searching for things like ?foo=bar&foo=baz). We do this by creating an array of all values. i.e.:
### FilterTags component
Similar to the way the list grabs data based on changes to the react-router params, the `FilterTags` component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array). Both key and value are displayed for the tag.
### qs utility
The qs (queryset) utility is used to make the search speak the language of the REST API. The main functions of the utilities are to:
- add, replace and remove filters
- translate filters as url params (for linking and maintaining state), in-memory representation (as JS objects), and params that Django REST Framework understands.
More info in the below sections:
#### Encoding for UI vs. API
For the UI url params, we want to only encode those params that aren't defaults, as the default behavior was defined through configuration and we don't need these in the url as a source of truth. For the API, we need to pass these params so that they are taken into account when the response is built.
#### mergeParams vs. replaceParams
**mergeParams** is used to suppport putting values with the same key
From a UX perspective, we wanted to be able to support searching on the same key multiple times (i.e. searching for things like `?foo=bar&foo=baz`). We do this by creating an array of all values. i.e.:
```
{
@ -118,36 +283,15 @@ From a UX perspective, we wanted to be able to support searching on the same key
}
```
Changes to encodeQueryString and parseQueryString were made to convert between a single value string representation and multiple value array representations. Test cases were also added to qs.test.js.
Concatenating terms in this way gives you the intersection of both terms (i.e. foo must be "bar" and "baz"). This is helpful for the most-common type of searching, substring (`__icontains`) searches. This will increase filtering, allowing the user to drill-down into the list as terms are added.
In addition, we needed to make sure any changes to the params that are not handled by search (page, page_size, and order_by) were updated by replacing the single value, rather than adding multiple values with the array representation. This additional piece of the specification was made in the newly created addParams and removeParams qs functions and a few test-cases were written to verify this.
**replaceParams** is used to support sorting, setting page_size, etc. These params only allow one choice, and we need to replace a particular key's value if one is passed.
The api is coupled with the qs util through the paramsSerializer, due to the fact we need axios to support the array for duplicate key values object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys.
#### Working with REST API
# UX considerations
The REST API is coupled with the qs util through the `paramsSerializer`, due to the fact we need axios to support the array for duplicate key values in the object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys automatically.
**UX should be more tags always equates to more filtering. (so "and" logic not "or")**
Also, for simple search results should be returned that partially match value (i.e. use icontains prefix)
**ALL query params namespaced and in url bar**
- this includes lists that aren't necessarily hyperlinked, like lookup lists.
- the reason behind this is so we can treat the url bar as the source of truth for queries always
- currently /#/organizations/add?lookup.name=bar -> will eventually be something like /#/organizations/add?ig_lookup.name=bar
- any params that have both a key AND value that is in the defaultParams section of the qs config should be stripped out of the search string
**django fuzzy search (?search=) is not accessible outside of "advanced search"**
- How "search" query param works
- in current smart search typing a term with no key utilizes search= i.e. for "foo" tag, ?search=foo is given
- search= looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well)
- note that search= tags are OR'd together
- search=foo&name=bar returns items that have a name field of bar (not case insensitive) AND some text field with foo on it
- search=foo&search=bar&name=baz returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name)
- similarly ?related__search= looks on the static list of "guesses" for models related to the endpoint
- the specific fields are not "searched" for related__search
- related__search not currently used in awx ui
# Advanced Search - this section is a mess, update eventually
**a note on typing in a smart search query**
@ -155,12 +299,6 @@ In order to not support a special "language" or "syntax" for crafting the query
Since all search bars are represented in the url, for users who want to input a string to filter results in a single step, typing directly in the url to achieve the filter is acceptable.
**a note on clicking a tag to putting it back into the search bar**
This was brought up as a nice to have when we were discussing features. There isn't a way we would be able to know if the user created the tag from the smart search or simple search interface? that info is not traceable using the query params as the exclusive source of truth
We have decided to not try to tackle this up front with our advanced search implementation, and may go back to this based on user feedback at a later time.
# Advanced search notes
Current thinking is Advanced Search will be post-3.6, or at least late 3.6 after awx features and "simple search" with the left dropdown and right input for the above phase 1 lists.

View File

@ -142,21 +142,57 @@ class AddResourceRole extends React.Component {
} = this.state;
const { onClose, roles, i18n } = this.props;
const userColumns = [
const userSearchColumns = [
{
name: i18n._(t`Username`),
key: 'username',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamColumns = [
const userSortColumns = [
{
name: i18n._(t`Username`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamSearchColumns = [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
];
const teamSortColumns = [
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
];
@ -207,7 +243,8 @@ class AddResourceRole extends React.Component {
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
columns={userColumns}
searchColumns={userSearchColumns}
sortColumns={userSortColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
onSearch={readUsers}
@ -218,7 +255,8 @@ class AddResourceRole extends React.Component {
)}
{selectedResource === 'teams' && (
<SelectResourceStep
columns={teamColumns}
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={this.handleResourceCheckboxClick}
onSearch={readTeams}
selectedLabel={i18n._(t`Selected`)}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { SearchColumns, SortColumns } from '@types';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
@ -23,7 +24,11 @@ class SelectResourceStep extends React.Component {
this.qsConfig = getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: props.sortedColumnKey,
order_by: `${
props.sortColumns.filter(col => col.key === 'name').length
? 'name'
: 'username'
}`,
});
}
@ -69,7 +74,8 @@ class SelectResourceStep extends React.Component {
const { isInitialized, isLoading, count, error, resources } = this.state;
const {
columns,
searchColumns,
sortColumns,
displayKey,
onRowClick,
selectedLabel,
@ -99,8 +105,9 @@ class SelectResourceStep extends React.Component {
items={resources}
itemCount={count}
qsConfig={this.qsConfig}
toolbarColumns={columns}
onRowClick={onRowClick}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
@ -123,21 +130,22 @@ class SelectResourceStep extends React.Component {
}
SelectResourceStep.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
searchColumns: SearchColumns,
sortColumns: SortColumns,
displayKey: PropTypes.string,
onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired,
selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
sortedColumnKey: PropTypes.string,
};
SelectResourceStep.defaultProps = {
searchColumns: null,
sortColumns: null,
displayKey: 'name',
onRowClick: () => {},
selectedLabel: null,
selectedResourceRows: [],
sortedColumnKey: 'name',
};
export { SelectResourceStep as _SelectResourceStep };

View File

@ -6,8 +6,19 @@ import { sleep } from '../../../testUtils/testUtils';
import SelectResourceStep from './SelectResourceStep';
describe('<SelectResourceStep />', () => {
const columns = [
{ name: 'Username', key: 'username', isSortable: true, isSearchable: true },
const searchColumns = [
{
name: 'Username',
key: 'username',
isDefault: true,
},
];
const sortColumns = [
{
name: 'Username',
key: 'username',
},
];
afterEach(() => {
jest.restoreAllMocks();
@ -15,11 +26,11 @@ describe('<SelectResourceStep />', () => {
test('initially renders without crashing', () => {
shallow(
<SelectResourceStep
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
sortedColumnKey="username"
/>
);
});
@ -36,11 +47,11 @@ describe('<SelectResourceStep />', () => {
});
mountWithContexts(
<SelectResourceStep
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
sortedColumnKey="username"
/>
);
expect(handleSearch).toHaveBeenCalledWith({
@ -68,12 +79,12 @@ describe('<SelectResourceStep />', () => {
});
const wrapper = await mountWithContexts(
<SelectResourceStep
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>,
{
context: { router: { history, route: { location: history.location } } },
@ -102,12 +113,12 @@ describe('<SelectResourceStep />', () => {
};
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => ({ data })}
selectedResourceRows={[]}
sortedColumnKey="username"
/>
);
await sleep(0);

View File

@ -2,75 +2,21 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Checkbox,
Toolbar as PFToolbar,
ToolbarGroup as PFToolbarGroup,
ToolbarItem,
} from '@patternfly/react-core';
import { Checkbox } from '@patternfly/react-core';
import styled from 'styled-components';
import { SearchIcon } from '@patternfly/react-icons';
import {
DataToolbar,
DataToolbarContent,
DataToolbarGroup,
DataToolbarToggleGroup,
DataToolbarItem,
} from '@patternfly/react-core/dist/umd/experimental';
import ExpandCollapse from '../ExpandCollapse';
import Search from '../Search';
import Sort from '../Sort';
import VerticalSeparator from '../VerticalSeparator';
import { QSConfig } from '@types';
const AWXToolbar = styled.div`
--awx-toolbar--BackgroundColor: var(--pf-global--BackgroundColor--light-100);
--awx-toolbar--BorderColor: #ebebeb;
--awx-toolbar--BorderWidth: var(--pf-global--BorderWidth--sm);
--pf-global--target-size--MinHeight: 0;
--pf-global--target-size--MinWidth: 0;
--pf-global--FontSize--md: 14px;
border-bottom: var(--awx-toolbar--BorderWidth) solid
var(--awx-toolbar--BorderColor);
background-color: var(--awx-toolbar--BackgroundColor);
display: flex;
min-height: 70px;
flex-grow: 1;
`;
const Toolbar = styled(PFToolbar)`
flex-grow: 1;
margin-left: 20px;
margin-right: 20px;
`;
const ToolbarGroup = styled(PFToolbarGroup)`
&&& {
margin: 0;
}
`;
const ColumnLeft = styled.div`
display: flex;
flex-basis: ${props => (props.fillWidth ? 'auto' : '100%')};
flex-grow: ${props => (props.fillWidth ? '1' : '0')};
justify-content: flex-start;
align-items: center;
padding: 10px 0 8px 0;
@media screen and (min-width: 980px) {
flex-basis: ${props => (props.fillWidth ? 'auto' : '50%')};
}
`;
const ColumnRight = styled.div`
display: flex;
flex-basis: ${props => (props.fillWidth ? 'auto' : '100%')};
flex-grow: 0;
justify-content: flex-start;
align-items: center;
padding: 8px 0 10px 0;
@media screen and (min-width: 980px) {
flex-basis: ${props => (props.fillWidth ? 'auto' : '50%')};
}
`;
import { SearchColumns, SortColumns, QSConfig } from '@types';
const AdditionalControlsWrapper = styled.div`
display: flex;
@ -83,21 +29,34 @@ const AdditionalControlsWrapper = styled.div`
}
`;
const AdditionalControlsDataToolbarGroup = styled(DataToolbarGroup)`
margin-left: auto;
margin-right: 0 !important;
`;
const DataToolbarSeparator = styled(DataToolbarItem)`
width: 1px !important;
height: 30px !important;
margin-left: 3px !important;
margin-right: 10px !important;
`;
class DataListToolbar extends React.Component {
render() {
const {
columns,
clearAllFilters,
searchColumns,
sortColumns,
showSelectAll,
isAllSelected,
isCompact,
fillWidth,
onSort,
onSearch,
onReplaceSearch,
onRemove,
onCompact,
onExpand,
onSelectAll,
sortOrder,
sortedColumnKey,
additionalControls,
i18n,
qsConfig,
@ -105,93 +64,93 @@ class DataListToolbar extends React.Component {
const showExpandCollapse = onCompact && onExpand;
return (
<AWXToolbar>
<Toolbar css={fillWidth ? 'margin-right: 0; margin-left: 0' : ''}>
<ColumnLeft fillWidth={fillWidth}>
{showSelectAll && (
<Fragment>
<ToolbarItem>
<Checkbox
isChecked={isAllSelected}
onChange={onSelectAll}
aria-label={i18n._(t`Select all`)}
id="select-all"
/>
</ToolbarItem>
<VerticalSeparator />
</Fragment>
)}
<ToolbarItem css="flex-grow: 1;">
<DataToolbar
id={`${qsConfig.namespace}-list-toolbar`}
clearAllFilters={clearAllFilters}
collapseListedFiltersBreakpoint="xl"
>
<DataToolbarContent>
{showSelectAll && (
<DataToolbarGroup>
<DataToolbarItem>
<Checkbox
isChecked={isAllSelected}
onChange={onSelectAll}
aria-label={i18n._(t`Select all`)}
id="select-all"
/>
</DataToolbarItem>
<DataToolbarSeparator variant="separator" />
</DataToolbarGroup>
)}
<DataToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="xl">
<DataToolbarItem>
<Search
qsConfig={qsConfig}
columns={columns}
columns={searchColumns}
onSearch={onSearch}
sortedColumnKey={sortedColumnKey}
onReplaceSearch={onReplaceSearch}
onRemove={onRemove}
/>
</ToolbarItem>
<VerticalSeparator />
</ColumnLeft>
<ColumnRight fillWidth={fillWidth}>
<ToolbarItem>
<Sort
columns={columns}
onSort={onSort}
sortOrder={sortOrder}
sortedColumnKey={sortedColumnKey}
/>
</ToolbarItem>
</DataToolbarItem>
<DataToolbarItem>
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
</DataToolbarItem>
</DataToolbarToggleGroup>
<DataToolbarGroup>
{showExpandCollapse && (
<Fragment>
<VerticalSeparator />
<ToolbarGroup>
<DataToolbarItem>
<ExpandCollapse
isCompact={isCompact}
onCompact={onCompact}
onExpand={onExpand}
/>
</ToolbarGroup>
{additionalControls && <VerticalSeparator />}
</DataToolbarItem>
</Fragment>
)}
<AdditionalControlsWrapper>
{additionalControls}
</AdditionalControlsWrapper>
</ColumnRight>
</Toolbar>
</AWXToolbar>
</DataToolbarGroup>
<AdditionalControlsDataToolbarGroup>
<DataToolbarItem>
<AdditionalControlsWrapper>
{additionalControls}
</AdditionalControlsWrapper>
</DataToolbarItem>
</AdditionalControlsDataToolbarGroup>
</DataToolbarContent>
</DataToolbar>
);
}
}
DataListToolbar.propTypes = {
clearAllFilters: PropTypes.func,
qsConfig: QSConfig.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
searchColumns: SearchColumns.isRequired,
sortColumns: SortColumns.isRequired,
showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool,
isCompact: PropTypes.bool,
fillWidth: PropTypes.bool,
onCompact: PropTypes.func,
onExpand: PropTypes.func,
onSearch: PropTypes.func,
onReplaceSearch: PropTypes.func,
onSelectAll: PropTypes.func,
onSort: PropTypes.func,
sortOrder: PropTypes.string,
sortedColumnKey: PropTypes.string,
additionalControls: PropTypes.arrayOf(PropTypes.node),
};
DataListToolbar.defaultProps = {
clearAllFilters: null,
showSelectAll: false,
isAllSelected: false,
isCompact: false,
fillWidth: false,
onCompact: null,
onExpand: null,
onSearch: null,
onReplaceSearch: null,
onSelectAll: null,
onSort: null,
sortOrder: 'ascending',
sortedColumnKey: 'name',
additionalControls: [],
};

View File

@ -20,14 +20,13 @@ describe('<DataListToolbar />', () => {
});
const onSearch = jest.fn();
const onReplaceSearch = jest.fn();
const onSort = jest.fn();
const onSelectAll = jest.fn();
test('it triggers the expected callbacks', () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
];
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const sortColumns = [{ name: 'Name', key: 'name' }];
const search = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
const selectAll = 'input[aria-label="Select all"]';
@ -38,10 +37,10 @@ describe('<DataListToolbar />', () => {
qsConfig={QS_CONFIG}
isAllSelected={false}
showExpandCollapse
sortedColumnKey="name"
sortOrder="ascending"
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
showSelectAll
@ -74,19 +73,28 @@ describe('<DataListToolbar />', () => {
const searchDropdownMenuItems =
'DropdownMenu > ul[aria-labelledby="awx-search"]';
const multipleColumns = [
{ name: 'Foo', key: 'foo', isSortable: true, isSearchable: true },
{ name: 'Bar', key: 'bar', isSortable: true, isSearchable: true },
{ name: 'Bakery', key: 'bakery', isSortable: true },
{ name: 'Baz', key: 'baz' },
const NEW_QS_CONFIG = {
namespace: 'organization',
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const searchColumns = [
{ name: 'Foo', key: 'foo', isDefault: true },
{ name: 'Bar', key: 'bar' },
];
const sortColumns = [
{ name: 'Foo', key: 'foo' },
{ name: 'Bar', key: 'bar' },
{ name: 'Bakery', key: 'Bakery' },
];
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="foo"
sortOrder="ascending"
columns={multipleColumns}
qsConfig={NEW_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSort={onSort}
/>
);
@ -106,10 +114,9 @@ describe('<DataListToolbar />', () => {
searchDropdownItems.at(0).simulate('click', mockedSortEvent);
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="foo"
sortOrder="descending"
columns={multipleColumns}
qsConfig={NEW_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSort={onSort}
/>
);
@ -145,77 +152,104 @@ describe('<DataListToolbar />', () => {
});
test('it displays correct sort icon', () => {
const downNumericIconSelector = 'SortNumericDownIcon';
const upNumericIconSelector = 'SortNumericUpIcon';
const downAlphaIconSelector = 'SortAlphaDownIcon';
const upAlphaIconSelector = 'SortAlphaUpIcon';
const NUM_QS_CONFIG = {
namespace: 'organization',
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: 'id' },
integerFields: ['page', 'page_size', 'id'],
};
const numericColumns = [
{ name: 'ID', key: 'id', isSortable: true, isNumeric: true },
];
const alphaColumns = [
{ name: 'Name', key: 'name', isSortable: true, isNumeric: false },
const NUM_DESC_QS_CONFIG = {
namespace: 'organization',
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: '-id' },
integerFields: ['page', 'page_size', 'id'],
};
const ALPH_QS_CONFIG = {
namespace: 'organization',
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size', 'id'],
};
const ALPH_DESC_QS_CONFIG = {
namespace: 'organization',
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: '-name' },
integerFields: ['page', 'page_size', 'id'],
};
const forwardNumericIconSelector = 'SortNumericDownIcon';
const reverseNumericIconSelector = 'SortNumericDownAltIcon';
const forwardAlphaIconSelector = 'SortAlphaDownIcon';
const reverseAlphaIconSelector = 'SortAlphaDownAltIcon';
const numericColumns = [{ name: 'ID', key: 'id' }];
const alphaColumns = [{ name: 'Name', key: 'name' }];
const searchColumns = [
{ name: 'Name', key: 'name', isDefault: true },
{ name: 'ID', key: 'id' },
];
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="id"
sortOrder="descending"
columns={numericColumns}
qsConfig={NUM_DESC_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={numericColumns}
/>
);
const downNumericIcon = toolbar.find(downNumericIconSelector);
expect(downNumericIcon.length).toBe(1);
const reverseNumericIcon = toolbar.find(reverseNumericIconSelector);
expect(reverseNumericIcon.length).toBe(1);
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="id"
sortOrder="ascending"
columns={numericColumns}
qsConfig={NUM_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={numericColumns}
/>
);
const upNumericIcon = toolbar.find(upNumericIconSelector);
expect(upNumericIcon.length).toBe(1);
const forwardNumericIcon = toolbar.find(forwardNumericIconSelector);
expect(forwardNumericIcon.length).toBe(1);
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="name"
sortOrder="descending"
columns={alphaColumns}
qsConfig={ALPH_DESC_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={alphaColumns}
/>
);
const downAlphaIcon = toolbar.find(downAlphaIconSelector);
expect(downAlphaIcon.length).toBe(1);
const reverseAlphaIcon = toolbar.find(reverseAlphaIconSelector);
expect(reverseAlphaIcon.length).toBe(1);
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
sortedColumnKey="name"
sortOrder="ascending"
columns={alphaColumns}
qsConfig={ALPH_QS_CONFIG}
searchColumns={searchColumns}
sortColumns={alphaColumns}
/>
);
const upAlphaIcon = toolbar.find(upAlphaIconSelector);
expect(upAlphaIcon.length).toBe(1);
const forwardAlphaIcon = toolbar.find(forwardAlphaIconSelector);
expect(forwardAlphaIcon.length).toBe(1);
});
test('should render additionalControls', () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
];
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const sortColumns = [{ name: 'Name', key: 'name' }];
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
additionalControls={[
@ -232,19 +266,17 @@ describe('<DataListToolbar />', () => {
});
test('it triggers the expected callbacks', () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
];
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const sortColumns = [{ name: 'Name', key: 'name' }];
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
isAllSelected
showExpandCollapse
sortedColumnKey="name"
sortOrder="ascending"
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
showSelectAll

View File

@ -1,102 +0,0 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import { parseQueryString } from '@util/qs';
import { ChipGroup as _ChipGroup, Chip } from '@components/Chip';
import VerticalSeparator from '@components/VerticalSeparator';
const FilterTagsRow = styled.div`
display: flex;
padding: 15px 20px;
border-top: 1px solid #d2d2d2;
font-size: 14px;
align-items: center;
`;
const ResultCount = styled.span`
font-weight: bold;
`;
const FilterLabel = styled.span`
padding-right: 20px;
`;
const ChipGroup = styled(_ChipGroup)`
li.pf-m-overflow {
display: none;
}
`;
// remove non-default query params so they don't show up as filter tags
const filterDefaultParams = (paramsArr, config) => {
const defaultParamsKeys = Object.keys(config.defaultParams);
return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1);
};
const FilterTags = ({
i18n,
itemCount,
qsConfig,
location,
onRemove,
onRemoveAll,
}) => {
const queryParams = parseQueryString(qsConfig, location.search);
const queryParamsArr = [];
const nonDefaultParams = filterDefaultParams(
Object.keys(queryParams),
qsConfig
);
nonDefaultParams.forEach(key => {
const label = key
.replace('__icontains', '')
.split('_')
.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ');
if (Array.isArray(queryParams[key])) {
queryParams[key].forEach(val =>
queryParamsArr.push({ key, value: val, label })
);
} else {
queryParamsArr.push({ key, value: queryParams[key], label });
}
});
return (
queryParamsArr.length > 0 && (
<FilterTagsRow>
<ResultCount>{i18n._(t`${itemCount} results`)}</ResultCount>
<VerticalSeparator />
<FilterLabel>{i18n._(t`Active Filters:`)}</FilterLabel>
<ChipGroup defaultIsOpen>
{queryParamsArr.map(({ key, label, value }) => (
<Chip
className="searchTagChip"
key={`${key}__${value}`}
isReadOnly={false}
onClick={() => onRemove(key, value)}
>
<b>{label}:</b>&nbsp;{value}
</Chip>
))}
<div className="pf-c-chip pf-m-overflow">
<Button
variant="plain"
type="button"
aria-label={i18n._(t`Clear all search filters`)}
onClick={onRemoveAll}
>
<span className="pf-c-chip__text">{i18n._(t`Clear all`)}</span>
</Button>
</div>
</ChipGroup>
</FilterTagsRow>
)
);
};
export default withI18n()(withRouter(FilterTags));

View File

@ -1,51 +0,0 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import FilterTags from './FilterTags';
describe('<ExpandCollapse />', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: [],
};
const onRemoveFn = jest.fn();
const onRemoveAllFn = jest.fn();
test('initially renders without crashing', () => {
const wrapper = mountWithContexts(
<FilterTags
qsConfig={qsConfig}
onRemove={onRemoveFn}
onRemoveAll={onRemoveAllFn}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('renders non-default param tags based on location history', () => {
const history = createMemoryHistory({
initialEntries: [
'/foo?item.page=1&item.page_size=2&item.name__icontains=bar&item.job_type__icontains=project',
],
});
const wrapper = mountWithContexts(
<FilterTags
qsConfig={qsConfig}
onRemove={onRemoveFn}
onRemoveAll={onRemoveAllFn}
/>,
{
context: { router: { history, route: { location: history.location } } },
}
);
const chips = wrapper.find('.pf-c-chip.searchTagChip');
expect(chips.length).toBe(2);
const chipLabels = wrapper.find('.pf-c-chip__text b');
expect(chipLabels.length).toBe(2);
expect(chipLabels.at(0).text()).toEqual('Name:');
expect(chipLabels.at(1).text()).toEqual('Job Type:');
wrapper.unmount();
});
});

View File

@ -1 +0,0 @@
export { default } from './FilterTags';

View File

@ -1,10 +1,12 @@
import React, { Fragment } from 'react';
import PropTypes, { arrayOf, shape, string, bool } from 'prop-types';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import {
DataToolbar,
DataToolbarContent,
} from '@patternfly/react-core/dist/umd/experimental';
import DataListToolbar from '@components/DataListToolbar';
import FilterTags from '@components/FilterTags';
import {
encodeNonDefaultQueryString,
@ -13,7 +15,7 @@ import {
replaceParams,
removeParams,
} from '@util/qs';
import { QSConfig } from '@types';
import { QSConfig, SearchColumns, SortColumns } from '@types';
const EmptyStateControlsWrapper = styled.div`
display: flex;
@ -31,29 +33,34 @@ class ListHeader extends React.Component {
super(props);
this.handleSearch = this.handleSearch.bind(this);
this.handleReplaceSearch = this.handleReplaceSearch.bind(this);
this.handleSort = this.handleSort.bind(this);
this.handleRemove = this.handleRemove.bind(this);
this.handleRemoveAll = this.handleRemoveAll.bind(this);
}
getSortOrder() {
const { qsConfig, location } = this.props;
const queryParams = parseQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return [queryParams.order_by.substr(1), 'descending'];
}
return [queryParams.order_by, 'ascending'];
}
handleSearch(key, value) {
const { location, qsConfig } = this.props;
let params = parseQueryString(qsConfig, location.search);
params = mergeParams(params, { [key]: value });
params = replaceParams(params, { page: 1 });
this.pushHistoryState(params);
}
handleReplaceSearch(key, value) {
const { location, qsConfig } = this.props;
const oldParams = parseQueryString(qsConfig, location.search);
this.pushHistoryState(mergeParams(oldParams, { [key]: value }));
this.pushHistoryState(replaceParams(oldParams, { [key]: value }));
}
handleRemove(key, value) {
const { location, qsConfig } = this.props;
const oldParams = parseQueryString(qsConfig, location.search);
let oldParams = parseQueryString(qsConfig, location.search);
if (parseInt(value, 10)) {
oldParams = removeParams(qsConfig, oldParams, {
[key]: parseInt(value, 10),
});
}
this.pushHistoryState(removeParams(qsConfig, oldParams, { [key]: value }));
}
@ -83,44 +90,40 @@ class ListHeader extends React.Component {
const {
emptyStateControls,
itemCount,
columns,
searchColumns,
sortColumns,
renderToolbar,
qsConfig,
location,
} = this.props;
const [orderBy, sortOrder] = this.getSortOrder();
const params = parseQueryString(qsConfig, location.search);
const isEmpty = itemCount === 0 && Object.keys(params).length === 0;
return (
<Fragment>
{isEmpty ? (
<Fragment>
<EmptyStateControlsWrapper>
{emptyStateControls}
</EmptyStateControlsWrapper>
<FilterTags
itemCount={itemCount}
qsConfig={qsConfig}
onRemove={this.handleRemove}
onRemoveAll={this.handleRemoveAll}
/>
</Fragment>
<DataToolbar
id={`${qsConfig.namespace}-list-toolbar`}
clearAllFilters={this.handleRemoveAll}
collapseListedFiltersBreakpoint="md"
>
<DataToolbarContent>
<EmptyStateControlsWrapper>
{emptyStateControls}
</EmptyStateControlsWrapper>
</DataToolbarContent>
</DataToolbar>
) : (
<Fragment>
{renderToolbar({
sortedColumnKey: orderBy,
sortOrder,
columns,
searchColumns,
sortColumns,
onSearch: this.handleSearch,
onReplaceSearch: this.handleReplaceSearch,
onSort: this.handleSort,
onRemove: this.handleRemove,
clearAllFilters: this.handleRemoveAll,
qsConfig,
})}
<FilterTags
itemCount={itemCount}
qsConfig={qsConfig}
onRemove={this.handleRemove}
onRemoveAll={this.handleRemoveAll}
/>
</Fragment>
)}
</Fragment>
@ -131,14 +134,8 @@ class ListHeader extends React.Component {
ListHeader.propTypes = {
itemCount: PropTypes.number.isRequired,
qsConfig: QSConfig.isRequired,
columns: arrayOf(
shape({
name: string.isRequired,
key: string.isRequired,
isSortable: bool,
isSearchable: bool,
})
).isRequired,
searchColumns: SearchColumns.isRequired,
sortColumns: SortColumns.isRequired,
renderToolbar: PropTypes.func,
};

View File

@ -1,13 +1,12 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import ListHeader from './ListHeader';
describe('ListHeader', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: [],
};
const renderToolbarFn = jest.fn();
@ -17,9 +16,8 @@ describe('ListHeader', () => {
<ListHeader
itemCount={50}
qsConfig={qsConfig}
columns={[
{ name: 'foo', key: 'foo', isSearchable: true, isSortable: true },
]}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
renderToolbar={renderToolbarFn}
/>
);
@ -35,26 +33,16 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
columns={[
{ name: 'name', key: 'name', isSearchable: true, isSortable: true },
]}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
);
const toolbar = wrapper.find('DataListToolbar');
expect(toolbar.prop('sortedColumnKey')).toEqual('name');
expect(toolbar.prop('sortOrder')).toEqual('ascending');
toolbar.prop('onSort')('name', 'descending');
expect(history.location.search).toEqual('?item.order_by=-name');
await sleep(0);
wrapper.update();
expect(toolbar.prop('sortedColumnKey')).toEqual('name');
// TODO: this assertion required updating queryParams prop. Consider
// fixing after #147 is done:
// expect(toolbar.prop('sortOrder')).toEqual('descending');
toolbar.prop('onSort')('name', 'ascending');
toolbar.prop('onSort')('foo', 'descending');
expect(history.location.search).toEqual('?item.order_by=-foo');
toolbar.prop('onSort')('foo', 'ascending');
// since order_by = name is the default, that should be strip out of the search
expect(history.location.search).toEqual('');
});

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { bool, func, number, string, oneOfType } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CredentialsAPI } from '@api';
import { Credential } from '@types';
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
@ -26,6 +27,7 @@ function CredentialLookup({
credentialTypeId,
value,
history,
i18n,
}) {
const [credentials, setCredentials] = useState([]);
const [count, setCount] = useState(0);
@ -48,6 +50,8 @@ function CredentialLookup({
})();
}, [credentialTypeId, history.location.search]);
// TODO: replace credential type search with REST-based grabbing of cred types
return (
<FormGroup
fieldId="credential"
@ -71,6 +75,27 @@ function CredentialLookup({
optionCount={count}
header={label}
qsConfig={QS_CONFIG}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}

View File

@ -64,24 +64,21 @@ function InstanceGroupsLookup(props) {
value={state.selectedItems}
options={instanceGroups}
optionCount={count}
columns={[
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
name: i18n._(t`Credential Name`),
key: 'credential__name',
},
]}
sortColumns={[
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
name: i18n._(t`Name`),
key: 'name',
},
]}
multiple={state.multiple}

View File

@ -68,19 +68,25 @@ function InventoryLookup({
value={state.selectedItems}
options={inventories}
optionCount={count}
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
searchColumns={[
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
multiple={state.multiple}

View File

@ -122,12 +122,25 @@ function MultiCredentialsLookup(props) {
value={state.selectedItems}
options={credentials}
optionCount={credentialsCount}
columns={[
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
multiple={isMultiple}

View File

@ -70,6 +70,27 @@ function OrganizationLookup({
header={i18n._(t`Organization`)}
name="organization"
qsConfig={QS_CONFIG}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}

View File

@ -70,6 +70,41 @@ function ProjectLookup({
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
options: [
[``, i18n._(t`Manual`)],
[`git`, i18n._(t`Git`)],
[`hg`, i18n._(t`Mercurial`)],
[`svn`, i18n._(t`Subversion`)],
[`insights`, i18n._(t`Red Hat Insights`)],
],
},
{
name: i18n._(t`SCM URL`),
key: 'scm_url',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
options={projects}
optionCount={count}
multiple={state.multiple}

View File

@ -8,19 +8,27 @@ import {
string,
oneOfType,
} from 'prop-types';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import SelectedList from '../../SelectedList';
import PaginatedDataList from '../../PaginatedDataList';
import CheckboxListItem from '../../CheckboxListItem';
import DataListToolbar from '../../DataListToolbar';
import { QSConfig } from '@types';
import { QSConfig, SearchColumns, SortColumns } from '@types';
const ModalList = styled.div`
.pf-c-data-toolbar__content {
padding: 0 !important;
}
`;
function OptionsList({
value,
options,
optionCount,
columns,
searchColumns,
sortColumns,
multiple,
header,
name,
@ -33,7 +41,7 @@ function OptionsList({
i18n,
}) {
return (
<div>
<ModalList>
{value.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
@ -49,7 +57,8 @@ function OptionsList({
itemCount={optionCount}
pluralizedItemName={header}
qsConfig={qsConfig}
toolbarColumns={columns}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
hasContentLoading={isLoading}
onRowClick={selectItem}
renderItem={item => (
@ -67,7 +76,7 @@ function OptionsList({
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</div>
</ModalList>
);
}
@ -80,7 +89,8 @@ OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
columns: arrayOf(shape({})),
searchColumns: SearchColumns,
sortColumns: SortColumns,
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
@ -90,7 +100,8 @@ OptionsList.propTypes = {
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
columns: [],
searchColumns: [],
sortColumns: [],
};
export default withI18n()(OptionsList);

View File

@ -3,7 +3,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { getQSConfig } from '@util/qs';
import OptionsList from './OptionsList';
const qsConfig = getQSConfig('test', {});
const qsConfig = getQSConfig('test', { order_by: 'foo' });
describe('<OptionsList />', () => {
it('should display list of options', () => {
@ -17,7 +17,8 @@ describe('<OptionsList />', () => {
value={[]}
options={options}
optionCount={3}
columns={[]}
searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]}
sortColumns={[{ name: 'Foo', key: 'foo' }]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}
@ -39,7 +40,8 @@ describe('<OptionsList />', () => {
value={[options[1]]}
options={options}
optionCount={3}
columns={[]}
searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]}
sortColumns={[{ name: 'Foo', key: 'foo' }]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}

View File

@ -18,12 +18,6 @@ const QS_CONFIG = getQSConfig('notification', {
order_by: 'name',
});
const COLUMNS = [
{ key: 'name', name: 'Name', isSortable: true, isSearchable: true },
{ key: 'modified', name: 'Modified', isSortable: true, isNumeric: true },
{ key: 'created', name: 'Created', isSortable: true, isNumeric: true },
];
class NotificationList extends Component {
constructor(props) {
super(props);
@ -204,7 +198,43 @@ class NotificationList extends Component {
itemCount={itemCount}
pluralizedItemName={i18n._(t`Notifications`)}
qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'type',
options: [
['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)],
['hipchat', i18n._(t`Hipchat`)],
['irc', i18n._(t`IRC`)],
['mattermost', i18n._(t`Mattermost`)],
['pagerduty', i18n._(t`Pagerduty`)],
['rocketchat', i18n._(t`Rocket.Chat`)],
['slack', i18n._(t`Slack`)],
['twilio', i18n._(t`Twilio`)],
['webhook', i18n._(t`Webhook`)],
],
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderItem={notification => (
<NotificationListItem
key={notification.id}

View File

@ -1,5 +1,5 @@
import React, { Fragment } from 'react';
import PropTypes, { arrayOf, shape, string, bool } from 'prop-types';
import PropTypes from 'prop-types';
import { DataList } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -18,7 +18,7 @@ import {
replaceParams,
} from '@util/qs';
import { QSConfig } from '@types';
import { QSConfig, SearchColumns, SortColumns } from '@types';
import PaginatedDataListItem from './PaginatedDataListItem';
@ -66,21 +66,29 @@ class PaginatedDataList extends React.Component {
itemCount,
qsConfig,
renderItem,
toolbarColumns,
toolbarSearchColumns,
toolbarSortColumns,
pluralizedItemName,
showPageSizeOptions,
location,
i18n,
renderToolbar,
} = this.props;
const columns = toolbarColumns.length
? toolbarColumns
const searchColumns = toolbarSearchColumns.length
? toolbarSearchColumns
: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
];
const sortColumns = toolbarSortColumns.length
? toolbarSortColumns
: [
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
];
const queryParams = parseQueryString(qsConfig, location.search);
@ -117,7 +125,8 @@ class PaginatedDataList extends React.Component {
itemCount={itemCount}
renderToolbar={renderToolbar}
emptyStateControls={emptyStateControls}
columns={columns}
searchColumns={searchColumns}
sortColumns={sortColumns}
qsConfig={qsConfig}
/>
{Content}
@ -158,13 +167,8 @@ PaginatedDataList.propTypes = {
pluralizedItemName: PropTypes.string,
qsConfig: QSConfig.isRequired,
renderItem: PropTypes.func,
toolbarColumns: arrayOf(
shape({
name: string.isRequired,
key: string.isRequired,
isSortable: bool,
})
),
toolbarSearchColumns: SearchColumns,
toolbarSortColumns: SortColumns,
showPageSizeOptions: PropTypes.bool,
renderToolbar: PropTypes.func,
hasContentLoading: PropTypes.bool,
@ -175,7 +179,8 @@ PaginatedDataList.propTypes = {
PaginatedDataList.defaultProps = {
hasContentLoading: false,
contentError: null,
toolbarColumns: [],
toolbarSearchColumns: [],
toolbarSortColumns: [],
pluralizedItemName: 'Items',
showPageSizeOptions: true,
renderItem: item => <PaginatedDataListItem key={item.id} item={item} />,

View File

@ -162,24 +162,33 @@ class ResourceAccessList extends React.Component {
itemCount={itemCount}
pluralizedItemName="Roles"
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`First Name`),
key: 'first_name',
isSortable: true,
isSearchable: true,
},
toolbarSearchColumns={[
{
name: i18n._(t`Username`),
key: 'username',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Username`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
isSortable: true,
isSearchable: true,
},
]}
renderToolbar={props => (

View File

@ -1,98 +1,64 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withRouter } from 'react-router-dom';
import {
Button as PFButton,
Dropdown as PFDropdown,
Button,
ButtonVariant,
Dropdown,
DropdownPosition,
DropdownToggle,
DropdownItem,
Form,
FormGroup,
TextInput as PFTextInput,
InputGroup,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import {
DataToolbarGroup,
DataToolbarItem,
DataToolbarFilter,
} from '@patternfly/react-core/dist/umd/experimental';
import { SearchIcon } from '@patternfly/react-icons';
import { QSConfig } from '@types';
import { parseQueryString } from '@util/qs';
import { QSConfig, SearchColumns } from '@types';
import styled from 'styled-components';
const TextInput = styled(PFTextInput)`
min-height: 0px;
height: 30px;
--pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200);
--pf-c-form-control--BorderLeftColor: var(--pf-global--BorderColor--200);
`;
const Button = styled(PFButton)`
width: 34px;
padding: 0px;
::after {
border: var(--pf-c-button--BorderWidth) solid
var(--pf-global--BorderColor--200);
}
`;
const Dropdown = styled(PFDropdown)`
&&& {
/* Higher specificity required because we are selecting unclassed elements */
> button {
min-height: 30px;
min-width: 70px;
height: 30px;
padding: 0 10px;
margin: 0px;
::before {
border-color: var(--pf-global--BorderColor--200);
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
> span {
/* text element */
width: auto;
}
> svg {
/* caret icon */
margin: 0px;
padding-top: 3px;
padding-left: 3px;
}
}
}
`;
const NoOptionDropdown = styled.div`
align-self: stretch;
border: 1px solid var(--pf-global--BorderColor--200);
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
padding: 3px 7px;
border: 1px solid var(--pf-global--BorderColor--300);
padding: 5px 15px;
white-space: nowrap;
`;
const InputFormGroup = styled(FormGroup)`
flex: 1;
border-bottom-color: var(--pf-global--BorderColor--200);
`;
class Search extends React.Component {
constructor(props) {
super(props);
const { sortedColumnKey } = this.props;
const { columns } = this.props;
this.state = {
isSearchDropdownOpen: false,
searchKey: sortedColumnKey,
searchKey: columns.find(col => col.isDefault).key,
searchValue: '',
isFilterDropdownOpen: false,
};
this.handleSearchInputChange = this.handleSearchInputChange.bind(this);
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleTextKeyDown = this.handleTextKeyDown.bind(this);
this.handleFilterDropdownToggle = this.handleFilterDropdownToggle.bind(
this
);
this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind(
this
);
this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this);
}
handleDropdownToggle(isSearchDropdownOpen) {
@ -115,11 +81,9 @@ class Search extends React.Component {
const { onSearch, qsConfig } = this.props;
const isNonStringField =
qsConfig.integerFields.filter(field => field === searchKey).length ||
qsConfig.dateFields.filter(field => field === searchKey).length;
qsConfig.integerFields.find(field => field === searchKey) ||
qsConfig.dateFields.find(field => field === searchKey);
// TODO: this will probably become more sophisticated, where date
// fields and string fields are passed to a formatter
const actualSearchKey = isNonStringField
? searchKey
: `${searchKey}__icontains`;
@ -133,95 +97,213 @@ class Search extends React.Component {
this.setState({ searchValue });
}
handleTextKeyDown(e) {
if (e.key && e.key === 'Enter') {
this.handleSearch(e);
}
}
handleFilterDropdownToggle(isFilterDropdownOpen) {
this.setState({ isFilterDropdownOpen });
}
handleFilterDropdownSelect(key, event, actualValue) {
const { onSearch, onRemove } = this.props;
if (event.target.checked) {
onSearch(`or__${key}`, actualValue);
} else {
onRemove(`or__${key}`, actualValue);
}
}
handleFilterBooleanSelect(key, selection) {
const { onReplaceSearch } = this.props;
onReplaceSearch(key, selection);
}
render() {
const { up } = DropdownPosition;
const { columns, i18n } = this.props;
const { isSearchDropdownOpen, searchKey, searchValue } = this.state;
const { columns, i18n, onRemove, qsConfig, location } = this.props;
const {
isSearchDropdownOpen,
searchKey,
searchValue,
isFilterDropdownOpen,
} = this.state;
const { name: searchColumnName } = columns.find(
({ key }) => key === searchKey
);
const searchDropdownItems = columns
.filter(({ key, isSearchable }) => isSearchable && key !== searchKey)
.filter(({ key }) => key !== searchKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
const filterDefaultParams = (paramsArr, config) => {
const defaultParamsKeys = Object.keys(config.defaultParams || {});
return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1);
};
const getChipsByKey = () => {
const queryParams = parseQueryString(qsConfig, location.search);
const queryParamsByKey = {};
columns.forEach(({ name, key }) => {
queryParamsByKey[key] = { key, label: name, chips: [] };
});
const nonDefaultParams = filterDefaultParams(
Object.keys(queryParams || {}),
qsConfig
);
nonDefaultParams.forEach(key => {
const columnKey = key.replace('__icontains', '').replace('or__', '');
const label = columns.filter(
({ key: keyToCheck }) => columnKey === keyToCheck
).length
? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0]
.name
: columnKey;
queryParamsByKey[columnKey] = { key, label, chips: [] };
if (Array.isArray(queryParams[key])) {
queryParams[key].forEach(val =>
queryParamsByKey[columnKey].chips.push(val.toString())
);
} else {
queryParamsByKey[columnKey].chips.push(queryParams[key].toString());
}
});
return queryParamsByKey;
};
const chipsByKey = getChipsByKey();
return (
<Form autoComplete="off">
<div className="pf-c-input-group">
<DataToolbarGroup variant="filter-group">
<DataToolbarItem>
{searchDropdownItems.length > 0 ? (
<FormGroup
fieldId="searchKeyDropdown"
label={
<span className="pf-screen-reader">
{i18n._(t`Search key dropdown`)}
</span>
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
toggle={
<DropdownToggle
id="awx-search"
onToggle={this.handleDropdownToggle}
style={{ width: '100%' }}
>
{searchColumnName}
</DropdownToggle>
}
>
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
isOpen={isSearchDropdownOpen}
toggle={
<DropdownToggle
id="awx-search"
onToggle={this.handleDropdownToggle}
>
{searchColumnName}
</DropdownToggle>
}
dropdownItems={searchDropdownItems}
/>
</FormGroup>
isOpen={isSearchDropdownOpen}
dropdownItems={searchDropdownItems}
style={{ width: '100%' }}
/>
) : (
<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>
)}
<InputFormGroup
fieldId="searchValueTextInput"
label={
<span className="pf-screen-reader">
{i18n._(t`Search value text input`)}
</span>
}
style={{ width: '100%' }}
suppressClassNameWarning
</DataToolbarItem>
{columns.map(({ key, name, options, isBoolean }) => (
<DataToolbarFilter
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
deleteChip={(unusedKey, val) => {
onRemove(chipsByKey[key].key, val);
}}
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
key={key}
showToolbarItem={searchKey === key}
>
<TextInput
type="search"
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={this.handleSearchInputChange}
style={{ height: '30px' }}
/>
</InputFormGroup>
<Button
variant="tertiary"
type="submit"
aria-label={i18n._(t`Search submit button`)}
onClick={this.handleSearch}
>
<SearchIcon />
</Button>
</div>
</Form>
{(options && (
<Fragment>
{/* TODO: update value to being object
{ actualValue: optionKey, toString: () => label }
currently a pf bug that makes the checked logic
not work with object-based values */}
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips}
isExpanded={isFilterDropdownOpen}
placeholderText={`Filter by ${name.toLowerCase()}`}
>
{options.map(([optionKey]) => (
<SelectOption key={optionKey} value={optionKey} />
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterBooleanSelect(key, selection)
}
selections={chipsByKey[key].chips[0]}
isExpanded={isFilterDropdownOpen}
placeholderText={`Filter by ${name.toLowerCase()}`}
>
{/* TODO: update value to being object
{ actualValue: optionKey, toString: () => label }
currently a pf bug that makes the checked logic
not work with object-based values */}
<SelectOption key="true" value="true" />
<SelectOption key="false" value="false" />
</Select>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type={
(qsConfig.integerFields.find(
field => field === searchKey
) &&
'number') ||
'search'
}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={this.handleSearchInputChange}
onKeyDown={this.handleTextKeyDown}
/>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Search submit button`)}
onClick={this.handleSearch}
>
<SearchIcon />
</Button>
</InputGroup>
)}
</DataToolbarFilter>
))}
</DataToolbarGroup>
);
}
}
Search.propTypes = {
qsConfig: QSConfig.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: SearchColumns.isRequired,
onSearch: PropTypes.func,
sortedColumnKey: PropTypes.string,
onRemove: PropTypes.func,
};
Search.defaultProps = {
onSearch: null,
sortedColumnKey: 'name',
onRemove: null,
};
export default withI18n()(Search);
export default withI18n()(withRouter(Search));

View File

@ -1,4 +1,8 @@
import React from 'react';
import {
DataToolbar,
DataToolbarContent,
} from '@patternfly/react-core/dist/umd/experimental';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Search from './Search';
@ -19,9 +23,7 @@ describe('<Search />', () => {
});
test('it triggers the expected callbacks', () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
];
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const searchBtn = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
@ -29,12 +31,15 @@ describe('<Search />', () => {
const onSearch = jest.fn();
search = mountWithContexts(
<Search
qsConfig={QS_CONFIG}
sortedColumnKey="name"
columns={columns}
onSearch={onSearch}
/>
<DataToolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="md"
>
<DataToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</DataToolbarContent>
</DataToolbar>
);
search.find(searchTextInput).instance().value = 'test-321';
@ -46,17 +51,18 @@ describe('<Search />', () => {
});
test('handleDropdownToggle properly updates state', async () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
];
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
<Search
qsConfig={QS_CONFIG}
sortedColumnKey="name"
columns={columns}
onSearch={onSearch}
/>
<DataToolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="md"
>
<DataToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</DataToolbarContent>
</DataToolbar>
).find('Search');
expect(wrapper.state('isSearchDropdownOpen')).toEqual(false);
wrapper.instance().handleDropdownToggle(true);
@ -65,22 +71,20 @@ describe('<Search />', () => {
test('handleDropdownSelect properly updates state', async () => {
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
{
name: 'Description',
key: 'description',
isSortable: true,
isSearchable: true,
},
{ name: 'Name', key: 'name', isDefault: true },
{ name: 'Description', key: 'description' },
];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
<Search
qsConfig={QS_CONFIG}
sortedColumnKey="name"
columns={columns}
onSearch={onSearch}
/>
<DataToolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="md"
>
<DataToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</DataToolbarContent>
</DataToolbar>
).find('Search');
expect(wrapper.state('searchKey')).toEqual('name');
wrapper

View File

@ -1,75 +1,65 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import {
Button,
Dropdown as PFDropdown,
ButtonVariant,
Dropdown,
DropdownPosition,
DropdownToggle,
DropdownItem,
Tooltip,
InputGroup,
} from '@patternfly/react-core';
import {
SortAlphaDownIcon,
SortAlphaUpIcon,
SortAlphaDownAltIcon,
SortNumericDownIcon,
SortNumericUpIcon,
SortNumericDownAltIcon,
} from '@patternfly/react-icons';
import { parseQueryString } from '@util/qs';
import { SortColumns, QSConfig } from '@types';
import styled from 'styled-components';
const Dropdown = styled(PFDropdown)`
&&& {
> button {
min-height: 30px;
min-width: 70px;
height: 30px;
padding: 0 10px;
margin: 0px;
> span {
/* text element within dropdown */
width: auto;
}
> svg {
/* caret icon */
margin: 0px;
padding-top: 3px;
padding-left: 3px;
}
}
}
`;
const IconWrapper = styled.span`
> svg {
font-size: 18px;
}
`;
const SortButton = styled(Button)`
padding: 5px 8px;
margin-top: 3px;
&:hover {
background-color: #0166cc;
color: white;
}
`;
const SortBy = styled.span`
margin-right: 15px;
font-size: var(--pf-global--FontSize--md);
const NoOptionDropdown = styled.div`
align-self: stretch;
border: 1px solid var(--pf-global--BorderColor--300);
padding: 5px 15px;
white-space: nowrap;
border-bottom-color: var(--pf-global--BorderColor--200);
`;
class Sort extends React.Component {
constructor(props) {
super(props);
let sortKey;
let sortOrder;
let isNumeric;
const { qsConfig, location } = this.props;
const queryParams = parseQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
sortKey = queryParams.order_by.substr(1);
sortOrder = 'descending';
} else if (queryParams.order_by) {
sortKey = queryParams.order_by;
sortOrder = 'ascending';
}
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true;
} else {
isNumeric = false;
}
this.state = {
isSortDropdownOpen: false,
sortKey,
sortOrder,
isNumeric,
};
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
@ -82,34 +72,42 @@ class Sort extends React.Component {
}
handleDropdownSelect({ target }) {
const { columns, onSort, sortOrder } = this.props;
const { columns, onSort, qsConfig } = this.props;
const { sortOrder } = this.state;
const { innerText } = target;
const [{ key: searchKey }] = columns.filter(
({ name }) => name === innerText
);
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText);
this.setState({ isSortDropdownOpen: false });
onSort(searchKey, sortOrder);
let isNumeric;
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true;
} else {
isNumeric = false;
}
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric });
onSort(sortKey, sortOrder);
}
handleSort() {
const { onSort, sortedColumnKey, sortOrder } = this.props;
const { onSort } = this.props;
const { sortKey, sortOrder } = this.state;
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
onSort(sortedColumnKey, newSortOrder);
this.setState({ sortOrder: newSortOrder });
onSort(sortKey, newSortOrder);
}
render() {
const { up } = DropdownPosition;
const { columns, sortedColumnKey, sortOrder, i18n } = this.props;
const { isSortDropdownOpen } = this.state;
const [{ name: sortedColumnName, isNumeric }] = columns.filter(
({ key }) => key === sortedColumnKey
const { columns, i18n } = this.props;
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
const [{ name: sortedColumnName }] = columns.filter(
({ key }) => key === sortKey
);
const sortDropdownItems = columns
.filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey)
.filter(({ key }) => key !== sortKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
@ -119,65 +117,57 @@ class Sort extends React.Component {
let SortIcon;
if (isNumeric) {
SortIcon =
sortOrder === 'ascending' ? SortNumericUpIcon : SortNumericDownIcon;
sortOrder === 'ascending'
? SortNumericDownIcon
: SortNumericDownAltIcon;
} else {
SortIcon =
sortOrder === 'ascending' ? SortAlphaUpIcon : SortAlphaDownIcon;
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
}
return (
<React.Fragment>
{sortDropdownItems.length > 0 && (
<React.Fragment>
<SortBy>{i18n._(t`Sort By`)}</SortBy>
<Dropdown
style={{ marginRight: '10px' }}
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle
id="awx-sort"
onToggle={this.handleDropdownToggle}
>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
</React.Fragment>
<Fragment>
{sortedColumnName && (
<InputGroup>
{(sortDropdownItems.length > 0 && (
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle
id="awx-sort"
onToggle={this.handleDropdownToggle}
>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Sort`)}
onClick={this.handleSort}
>
<SortIcon />
</Button>
</InputGroup>
)}
<Tooltip
content={<div>{i18n._(t`Reverse Sort Order`)}</div>}
position="top"
>
<SortButton
onClick={this.handleSort}
variant="plain"
aria-label={i18n._(t`Sort`)}
>
<IconWrapper>
<SortIcon style={{ verticalAlign: '-0.225em' }} />
</IconWrapper>
</SortButton>
</Tooltip>
</React.Fragment>
</Fragment>
);
}
}
Sort.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
qsConfig: QSConfig.isRequired,
columns: SortColumns.isRequired,
onSort: PropTypes.func,
sortOrder: PropTypes.string,
sortedColumnKey: PropTypes.string,
};
Sort.defaultProps = {
onSort: null,
sortOrder: 'ascending',
sortedColumnKey: 'name',
};
export default withI18n()(Sort);
export default withI18n()(withRouter(Sort));

View File

@ -12,8 +12,17 @@ describe('<Sort />', () => {
});
test('it triggers the expected callbacks', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size'],
};
const columns = [
{ name: 'Name', key: 'name', isSortable: true, isSearchable: true },
{
name: 'Name',
key: 'name',
},
];
const sortBtn = 'button[aria-label="Sort"]';
@ -21,12 +30,7 @@ describe('<Sort />', () => {
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort
sortedColumnKey="name"
sortOrder="ascending"
columns={columns}
onSort={onSort}
/>
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
wrapper.find(sortBtn).simulate('click');
@ -36,22 +40,31 @@ describe('<Sort />', () => {
});
test('onSort properly passes back descending when ascending was passed as prop', () => {
const multipleColumns = [
{ name: 'Foo', key: 'foo', isSortable: true },
{ name: 'Bar', key: 'bar', isSortable: true },
{ name: 'Bakery', key: 'bakery', isSortable: true },
{ name: 'Baz', key: 'baz' },
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort
sortedColumnKey="foo"
sortOrder="ascending"
columns={multipleColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
const sortDropdownToggle = wrapper.find('Button');
expect(sortDropdownToggle.length).toBe(1);
@ -60,22 +73,31 @@ describe('<Sort />', () => {
});
test('onSort properly passes back ascending when descending was passed as prop', () => {
const multipleColumns = [
{ name: 'Foo', key: 'foo', isSortable: true },
{ name: 'Bar', key: 'bar', isSortable: true },
{ name: 'Bakery', key: 'bakery', isSortable: true },
{ name: 'Baz', key: 'baz' },
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: '-foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort
sortedColumnKey="foo"
sortOrder="descending"
columns={multipleColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
const sortDropdownToggle = wrapper.find('Button');
expect(sortDropdownToggle.length).toBe(1);
@ -84,22 +106,31 @@ describe('<Sort />', () => {
});
test('Changing dropdown correctly passes back new sort key', () => {
const multipleColumns = [
{ name: 'Foo', key: 'foo', isSortable: true },
{ name: 'Bar', key: 'bar', isSortable: true },
{ name: 'Bakery', key: 'bakery', isSortable: true },
{ name: 'Baz', key: 'baz' },
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort
sortedColumnKey="foo"
sortOrder="ascending"
columns={multipleColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } });
@ -107,22 +138,31 @@ describe('<Sort />', () => {
});
test('Opening dropdown correctly updates state', () => {
const multipleColumns = [
{ name: 'Foo', key: 'foo', isSortable: true },
{ name: 'Bar', key: 'bar', isSortable: true },
{ name: 'Bakery', key: 'bakery', isSortable: true },
{ name: 'Baz', key: 'baz' },
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort
sortedColumnKey="foo"
sortOrder="ascending"
columns={multipleColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
expect(wrapper.state('isSortDropdownOpen')).toEqual(false);
wrapper.instance().handleDropdownToggle(true);
@ -130,65 +170,70 @@ describe('<Sort />', () => {
});
test('It displays correct sort icon', () => {
const downNumericIconSelector = 'SortNumericDownIcon';
const upNumericIconSelector = 'SortNumericUpIcon';
const downAlphaIconSelector = 'SortAlphaDownIcon';
const upAlphaIconSelector = 'SortAlphaUpIcon';
const forwardNumericIconSelector = 'SortNumericDownIcon';
const reverseNumericIconSelector = 'SortNumericDownAltIcon';
const forwardAlphaIconSelector = 'SortAlphaDownIcon';
const reverseAlphaIconSelector = 'SortAlphaDownAltIcon';
const numericColumns = [
{ name: 'ID', key: 'id', isSortable: true, isNumeric: true },
];
const alphaColumns = [
{ name: 'Name', key: 'name', isSortable: true, isNumeric: false },
];
const qsConfigNumDown = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: '-id' },
integerFields: ['page', 'page_size', 'id'],
};
const qsConfigNumUp = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'id' },
integerFields: ['page', 'page_size', 'id'],
};
const qsConfigAlphaDown = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: '-name' },
integerFields: ['page', 'page_size'],
};
const qsConfigAlphaUp = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size'],
};
const numericColumns = [{ name: 'ID', key: 'id' }];
const alphaColumns = [{ name: 'Name', key: 'name' }];
const onSort = jest.fn();
sort = mountWithContexts(
<Sort
sortedColumnKey="id"
sortOrder="descending"
qsConfig={qsConfigNumDown}
columns={numericColumns}
onSort={onSort}
/>
);
const downNumericIcon = sort.find(downNumericIconSelector);
expect(downNumericIcon.length).toBe(1);
const reverseNumericIcon = sort.find(reverseNumericIconSelector);
expect(reverseNumericIcon.length).toBe(1);
sort = mountWithContexts(
<Sort
sortedColumnKey="id"
sortOrder="ascending"
columns={numericColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfigNumUp} columns={numericColumns} onSort={onSort} />
);
const upNumericIcon = sort.find(upNumericIconSelector);
expect(upNumericIcon.length).toBe(1);
const forwardNumericIcon = sort.find(forwardNumericIconSelector);
expect(forwardNumericIcon.length).toBe(1);
sort = mountWithContexts(
<Sort
sortedColumnKey="name"
sortOrder="descending"
qsConfig={qsConfigAlphaDown}
columns={alphaColumns}
onSort={onSort}
/>
);
const downAlphaIcon = sort.find(downAlphaIconSelector);
expect(downAlphaIcon.length).toBe(1);
const reverseAlphaIcon = sort.find(reverseAlphaIconSelector);
expect(reverseAlphaIcon.length).toBe(1);
sort = mountWithContexts(
<Sort
sortedColumnKey="name"
sortOrder="ascending"
columns={alphaColumns}
onSort={onSort}
/>
<Sort qsConfig={qsConfigAlphaUp} columns={alphaColumns} onSort={onSort} />
);
const upAlphaIcon = sort.find(upAlphaIconSelector);
expect(upAlphaIcon.length).toBe(1);
const forwardAlphaIcon = sort.find(forwardAlphaIconSelector);
expect(forwardAlphaIcon.length).toBe(1);
});
});

View File

@ -5,7 +5,7 @@ const Separator = styled.span`
display: inline-block;
width: 1px;
height: 30px;
margin-right: 20px;
margin-right: 27px;
margin-left: 20px;
background-color: #d7d7d7;
vertical-align: middle;

View File

@ -190,24 +190,25 @@ class HostsList extends Component {
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -177,6 +177,32 @@ function InventoryGroupsList({ i18n, location, match }) {
itemCount={groupCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Is Root Group`),
key: 'parents__isnull',
isBoolean: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderItem={item => (
<InventoryGroupItem
key={item.id}

View File

@ -161,7 +161,9 @@ describe('<InventoryGroupsList />', () => {
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
wrapper
.find('DataToolbar Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(
wrapper,
@ -193,7 +195,9 @@ describe('<InventoryGroupsList />', () => {
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
wrapper
.find('DataToolbar Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(
wrapper,

View File

@ -132,24 +132,25 @@ function InventoryHosts({ i18n, location, match }) {
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -174,24 +174,25 @@ class InventoriesList extends Component {
pluralizedItemName={i18n._(t`Inventories`)}
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -23,12 +23,16 @@ import { getQSConfig, parseQueryString } from '@util/qs';
import JobListItem from './JobListItem';
const QS_CONFIG = getQSConfig('job', {
page: 1,
page_size: 20,
order_by: '-finished',
not__launch_type: 'sync',
});
const QS_CONFIG = getQSConfig(
'job',
{
page: 1,
page_size: 20,
order_by: '-finished',
not__launch_type: 'sync',
},
['page', 'page_size', 'id']
);
class JobList extends Component {
constructor(props) {
@ -163,18 +167,67 @@ class JobList extends Component {
pluralizedItemName="Jobs"
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Label Name`),
key: 'labels__name',
},
{
name: i18n._(t`Job Type`),
key: `type`,
options: [
[`project_update`, i18n._(t`SCM Update`)],
[`inventory_update`, i18n._(t`Inventory Sync`)],
[`job`, i18n._(t`Playbook Run`)],
[`ad_hoc_command`, i18n._(t`Command`)],
[`system_job`, i18n._(t`Management Job`)],
[`workflow_job`, i18n._(t`Workflow Job`)],
],
},
{
name: i18n._(t`Launched By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Status`),
key: 'status',
options: [
[`new`, i18n._(t`New`)],
[`pending`, i18n._(t`Pending`)],
[`waiting`, i18n._(t`Waiting`)],
[`running`, i18n._(t`Running`)],
[`successful`, i18n._(t`Successful`)],
[`failed`, i18n._(t`Failed`)],
[`error`, i18n._(t`Error`)],
[`canceled`, i18n._(t`Canceled`)],
],
},
{
name: i18n._(t`Limit`),
key: 'job__limit',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Finished`),
key: 'finished',
isSortable: true,
isNumeric: true,
},
]}
renderToolbar={props => (

View File

@ -119,24 +119,25 @@ function OrganizationsList({ i18n }) {
pluralizedItemName="Organizations"
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { OrganizationsAPI } from '@api';
import PaginatedDataList from '@components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '@util/qs';
@ -12,7 +13,7 @@ const QS_CONFIG = getQSConfig('team', {
order_by: 'name',
});
function OrganizationTeams({ id }) {
function OrganizationTeams({ id, i18n }) {
const location = useLocation();
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(false);
@ -46,6 +47,27 @@ function OrganizationTeams({ id }) {
itemCount={itemCount}
pluralizedItemName="Teams"
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
/>
);
}
@ -55,4 +77,4 @@ OrganizationTeams.propTypes = {
};
export { OrganizationTeams as _OrganizationTeams };
export default OrganizationTeams;
export default withI18n()(OrganizationTeams);

View File

@ -156,24 +156,40 @@ class ProjectsList extends Component {
pluralizedItemName={i18n._(t`Projects`)}
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Type`),
key: 'type',
options: [
[``, i18n._(t`Manual`)],
[`git`, i18n._(t`Git`)],
[`hg`, i18n._(t`Mercurial`)],
[`svn`, i18n._(t`Subversion`)],
[`insights`, i18n._(t`Red Hat Insights`)],
],
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`SCM URL`),
key: 'scm_url',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -154,24 +154,29 @@ class TeamsList extends Component {
pluralizedItemName={i18n._(t`Teams`)}
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Organization Name`),
key: 'organization__name',
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (

View File

@ -214,24 +214,41 @@ class TemplatesList extends Component {
pluralizedItemName={i18n._(t`Templates`)}
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
isDefault: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
name: i18n._(t`Type`),
key: 'type',
options: [
[`job_template`, i18n._(t`Job Template`)],
[`workflow_job_template`, i18n._(t`Workflow Template`)],
],
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
name: i18n._(t`Playbook name`),
key: 'job_template__playbook',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Type`),
key: 'type',
},
]}
renderToolbar={props => (

View File

@ -154,24 +154,33 @@ class UsersList extends Component {
pluralizedItemName="Users"
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarColumns={[
toolbarSearchColumns={[
{
name: i18n._(t`Username`),
key: 'username',
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Username`),
key: 'username',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
isSortable: true,
isSearchable: true,
},
]}
renderToolbar={props => (

View File

@ -1,5 +1,6 @@
import {
shape,
exact,
arrayOf,
number,
string,
@ -249,3 +250,20 @@ export const Group = shape({
inventory: number,
variables: string,
});
export const SearchColumns = arrayOf(
exact({
name: string.isRequired,
key: string.isRequired,
isDefault: bool,
isBoolean: bool,
options: arrayOf(arrayOf(string, string)),
})
);
export const SortColumns = arrayOf(
exact({
name: string.isRequired,
key: string.isRequired,
})
);

View File

@ -14,6 +14,10 @@ export function getQSConfig(
if (!namespace) {
throw new Error('a QS namespace is required');
}
// if order_by isn't passed, default to name
if (!defaultParams.order_by) {
defaultParams.order_by = 'name';
}
return {
namespace,
defaultParams,

View File

@ -121,6 +121,20 @@ describe('qs (qs.js)', () => {
});
});
test('should set order_by in defaultParams if it is not passed', () => {
expect(
getQSConfig('organization', {
page: 1,
page_size: 5,
})
).toEqual({
namespace: 'organization',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size'],
dateFields: ['modified', 'created'],
});
});
test('should throw if no namespace given', () => {
expect(() => getQSConfig()).toThrow();
});
@ -132,7 +146,7 @@ describe('qs (qs.js)', () => {
};
expect(getQSConfig('inventory', defaults)).toEqual({
namespace: 'inventory',
defaultParams: { page: 1, page_size: 15 },
defaultParams: { page: 1, page_size: 15, order_by: 'name' },
integerFields: ['page', 'page_size'],
dateFields: ['modified', 'created'],
});