From 16f9411914a8ed4180dd830a8aeb7f8e548604f3 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 8 Nov 2019 13:14:19 -0500 Subject: [PATCH 01/18] update simple search doumentation and plan --- awx/ui_next/SEARCH.md | 326 ++++++++++++++++++++++++++++++------------ 1 file changed, 232 insertions(+), 94 deletions(-) diff --git a/awx/ui_next/SEARCH.md b/awx/ui_next/SEARCH.md index 16088827cc..497d424248 100644 --- a/awx/ui_next/SEARCH.md +++ b/awx/ui_next/SEARCH.md @@ -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: ``` ``` -## 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. From 8b9810e466cb849842b266742eec10c0ac10ef33 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 26 Nov 2019 17:23:53 -0500 Subject: [PATCH 02/18] update search and sort column configuration --- .../components/AddRole/AddResourceRole.jsx | 32 +++- .../components/AddRole/SelectResourceStep.jsx | 18 +- .../AddRole/SelectResourceStep.test.jsx | 31 ++-- .../DataListToolbar/DataListToolbar.jsx | 23 +-- .../DataListToolbar/DataListToolbar.test.jsx | 138 ++++++++++----- .../src/components/ListHeader/ListHeader.jsx | 33 +--- .../components/ListHeader/ListHeader.test.jsx | 31 ++-- .../Lookup/InstanceGroupsLookup.jsx | 12 +- .../src/components/Lookup/InventoryLookup.jsx | 15 +- .../components/Lookup/shared/OptionsList.jsx | 6 +- .../NotificationList/NotificationList.jsx | 36 +++- .../PaginatedDataList/PaginatedDataList.jsx | 37 ++-- .../ResourceAccessList/ResourceAccessList.jsx | 32 ++-- awx/ui_next/src/components/Search/Search.jsx | 15 +- .../src/components/Search/Search.test.jsx | 18 +- awx/ui_next/src/components/Sort/Sort.jsx | 76 +++++--- awx/ui_next/src/components/Sort/Sort.test.jsx | 167 +++++++++++++----- .../src/screens/Host/HostList/HostList.jsx | 23 ++- .../Inventory/InventoryList/InventoryList.jsx | 23 ++- .../src/screens/Job/JobList/JobList.jsx | 17 +- .../OrganizationList/OrganizationList.jsx | 23 ++- .../Project/ProjectList/ProjectList.jsx | 23 ++- .../src/screens/Team/TeamList/TeamList.jsx | 23 ++- .../Template/TemplateList/TemplateList.jsx | 23 ++- .../src/screens/User/UserList/UserList.jsx | 24 ++- awx/ui_next/src/types.js | 15 ++ awx/ui_next/src/util/qs.js | 4 + awx/ui_next/src/util/qs.test.js | 14 +- 28 files changed, 612 insertions(+), 320 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 596b3bbe97..f0542321b3 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -142,21 +142,33 @@ 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 }, ]; - const teamColumns = [ + const userSortColumns = [ { - name: i18n._(t`Name`), + name: i18n._(t`Username`), + key: 'username', + }, + ]; + + const teamSearchColumns = [ + { + name: i18n._(t`name`), + key: 'name', + isDefault: true + }, + ]; + + const teamSortColumns = [ + { + name: i18n._(t`name`), key: 'name', - isSortable: true, - isSearchable: true, }, ]; @@ -207,7 +219,8 @@ class AddResourceRole extends React.Component { {selectedResource === 'users' && ( col.key === 'name').length ? 'name' : 'username'}` }); } @@ -50,6 +51,8 @@ class SelectResourceStep extends React.Component { const { data } = await onSearch(queryParams); const { count, results } = data; + debugger; + this.setState({ resources: results, count, @@ -69,7 +72,8 @@ class SelectResourceStep extends React.Component { const { isInitialized, isLoading, count, error, resources } = this.state; const { - columns, + searchColumns, + sortColumns, displayKey, onRowClick, selectedLabel, @@ -99,8 +103,9 @@ class SelectResourceStep extends React.Component { items={resources} itemCount={count} qsConfig={this.qsConfig} - toolbarColumns={columns} onRowClick={onRowClick} + toolbarSearchColumns={searchColumns} + toolbarSortColumns={sortColumns} renderItem={item => ( i.id === item.id)} @@ -123,21 +128,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 }; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index 3d32abef06..6e37f1019d 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -6,8 +6,19 @@ import { sleep } from '../../../testUtils/testUtils'; import SelectResourceStep from './SelectResourceStep'; describe('', () => { - 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('', () => { test('initially renders without crashing', () => { shallow( {}} onSearch={() => {}} - sortedColumnKey="username" /> ); }); @@ -36,11 +47,11 @@ describe('', () => { }); mountWithContexts( {}} onSearch={handleSearch} - sortedColumnKey="username" /> ); expect(handleSearch).toHaveBeenCalledWith({ @@ -68,12 +79,12 @@ describe('', () => { }); const wrapper = await mountWithContexts( {}} onSearch={handleSearch} selectedResourceRows={selectedResourceRows} - sortedColumnKey="username" />, { context: { router: { history, route: { location: history.location } } }, @@ -102,12 +113,12 @@ describe('', () => { }; const wrapper = mountWithContexts( ({ data })} selectedResourceRows={[]} - sortedColumnKey="username" /> ); await sleep(0); diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 305a0f0ae2..7248dc0c51 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -8,14 +8,13 @@ import { ToolbarGroup as PFToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; - import styled from 'styled-components'; import ExpandCollapse from '../ExpandCollapse'; import Search from '../Search'; import Sort from '../Sort'; import VerticalSeparator from '../VerticalSeparator'; -import { QSConfig } from '@types'; +import { SearchColumns, SortColumns, QSConfig } from '@types'; const AWXToolbar = styled.div` --awx-toolbar--BackgroundColor: var(--pf-global--BackgroundColor--light-100); @@ -86,7 +85,8 @@ const AdditionalControlsWrapper = styled.div` class DataListToolbar extends React.Component { render() { const { - columns, + searchColumns, + sortColumns, showSelectAll, isAllSelected, isCompact, @@ -96,8 +96,6 @@ class DataListToolbar extends React.Component { onCompact, onExpand, onSelectAll, - sortOrder, - sortedColumnKey, additionalControls, i18n, qsConfig, @@ -124,9 +122,8 @@ class DataListToolbar extends React.Component { @@ -134,10 +131,9 @@ class DataListToolbar extends React.Component { {showExpandCollapse && ( @@ -165,7 +161,8 @@ class DataListToolbar extends React.Component { DataListToolbar.propTypes = { qsConfig: QSConfig.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, + searchColumns: SearchColumns.isRequired, + sortColumns: SortColumns.isRequired, showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, isCompact: PropTypes.bool, @@ -175,8 +172,6 @@ DataListToolbar.propTypes = { onSearch: PropTypes.func, onSelectAll: PropTypes.func, onSort: PropTypes.func, - sortOrder: PropTypes.string, - sortedColumnKey: PropTypes.string, additionalControls: PropTypes.arrayOf(PropTypes.node), }; @@ -190,8 +185,6 @@ DataListToolbar.defaultProps = { onSearch: null, onSelectAll: null, onSort: null, - sortOrder: 'ascending', - sortedColumnKey: 'name', additionalControls: [], }; diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx index 376cbc8efa..8e5b705244 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx @@ -23,11 +23,13 @@ describe('', () => { const onSort = jest.fn(); const onSelectAll = jest.fn(); - test('it triggers the expected callbacks', () => { - const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, + test('it triggers the expected callbacks', () => { + 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,9 +40,8 @@ describe('', () => { qsConfig={QS_CONFIG} isAllSelected={false} showExpandCollapse - sortedColumnKey="name" - sortOrder="ascending" - columns={columns} + searchColumns={searchColumns} + sortColumns={sortColumns} onSearch={onSearch} onSort={onSort} onSelectAll={onSelectAll} @@ -74,19 +75,28 @@ describe('', () => { 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( ); @@ -106,10 +116,9 @@ describe('', () => { searchDropdownItems.at(0).simulate('click', mockedSortEvent); toolbar = mountWithContexts( ); @@ -145,24 +154,57 @@ describe('', () => { }); test('it displays correct sort icon', () => { + const NUM_QS_CONFIG = { + namespace: 'organization', + dateFields: ['modified', 'created'], + defaultParams: { page: 1, page_size: 5, order_by: 'id' }, + integerFields: ['page', 'page_size', 'id'], + }; + + 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 downNumericIconSelector = 'SortNumericDownIcon'; const upNumericIconSelector = 'SortNumericUpIcon'; const downAlphaIconSelector = 'SortAlphaDownIcon'; const upAlphaIconSelector = 'SortAlphaUpIcon'; const numericColumns = [ - { name: 'ID', key: 'id', isSortable: true, isNumeric: true }, + { name: 'ID', key: 'id', isDefault: true }, ]; + const alphaColumns = [ - { name: 'Name', key: 'name', isSortable: true, isNumeric: false }, + { name: 'Name', key: 'name', isDefault: true }, + ]; + + const searchColumns = [ + { name: 'Name', key: 'name', isDefault: true }, + { name: 'ID', key: 'id' } ]; toolbar = mountWithContexts( ); @@ -171,10 +213,9 @@ describe('', () => { toolbar = mountWithContexts( ); @@ -183,10 +224,9 @@ describe('', () => { toolbar = mountWithContexts( ); @@ -195,10 +235,9 @@ describe('', () => { toolbar = mountWithContexts( ); @@ -207,14 +246,18 @@ describe('', () => { }); 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', isDefault: true } ]; toolbar = mountWithContexts( ', () => { }); 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( {renderToolbar({ - sortedColumnKey: orderBy, - sortOrder, - columns, + searchColumns, + sortColumns, onSearch: this.handleSearch, onSort: this.handleSort, qsConfig, @@ -131,14 +120,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, }; diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx index 9435f2e8b3..4480824a5a 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx @@ -7,7 +7,7 @@ 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,8 +17,11 @@ describe('ListHeader', () => { @@ -35,26 +38,20 @@ describe('ListHeader', () => { , { 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(''); }); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 20c2e0cf20..129eaf89d1 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -64,26 +64,24 @@ function InstanceGroupsLookup(props) { value={state.selectedItems} options={instanceGroups} optionCount={count} - columns={[ + searchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: false, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: false, - isNumeric: true, }, ]} + sortColumns={[{ + name: i18n._(t`Name`), + key: 'name' + }]} multiple={state.multiple} header={i18n._(t`Instance Groups`)} name="instanceGroups" diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 0286561a6a..67cd6bc965 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -68,21 +68,24 @@ function InventoryLookup({ value={state.selectedItems} options={inventories} optionCount={count} - columns={[ - { name: i18n._(t`Name`), key: 'name', isSortable: true }, + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: false, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: false, - isNumeric: true, }, ]} + sortColumns={[{ + name: i18n._(t`Name`), + key: 'name' + }]} multiple={state.multiple} header={i18n._(t`Inventory`)} name="inventory" diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index 0d8b92fed3..ae3451f9ff 100644 --- a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -14,7 +14,7 @@ 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'; function OptionsList({ value, @@ -80,7 +80,8 @@ OptionsList.propTypes = { value: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired, optionCount: number.isRequired, - columns: arrayOf(shape({})), + searchColumns: SearchColumns.isRequired, + sortColumns: SortColumns.isRequired, multiple: bool, qsConfig: QSConfig.isRequired, selectItem: func.isRequired, @@ -90,7 +91,6 @@ OptionsList.propTypes = { OptionsList.defaultProps = { multiple: false, renderItemChip: null, - columns: [], }; export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.jsx index 8ae2c2c3f3..088f50d725 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.jsx @@ -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,35 @@ 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`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} renderItem={notification => ( {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 => , diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index 68ad3fe60d..66fba00327 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -162,24 +162,34 @@ 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', + isDefault: true + }, + { + name: i18n._(t`First Name`), + key: 'first_name', }, { name: i18n._(t`Last Name`), key: 'last_name', - isSortable: true, - isSearchable: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index a33e71e070..6dda72a866 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -14,7 +14,7 @@ import { } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; -import { QSConfig } from '@types'; +import { QSConfig, SearchColumns } from '@types'; import styled from 'styled-components'; @@ -82,10 +82,11 @@ 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: '', }; @@ -142,7 +143,7 @@ class Search extends React.Component { ); const searchDropdownItems = columns - .filter(({ key, isSearchable }) => isSearchable && key !== searchKey) + .filter(({ key }) => key !== searchKey) .map(({ key, name }) => ( {name} @@ -214,14 +215,12 @@ class Search extends React.Component { Search.propTypes = { qsConfig: QSConfig.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSearch: PropTypes.func, - sortedColumnKey: PropTypes.string, + columns: SearchColumns.isRequired, + onSearch: PropTypes.func }; Search.defaultProps = { onSearch: null, - sortedColumnKey: 'name', }; export default withI18n()(Search); diff --git a/awx/ui_next/src/components/Search/Search.test.jsx b/awx/ui_next/src/components/Search/Search.test.jsx index d0ee274107..cddce90761 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -8,7 +8,7 @@ describe('', () => { const QS_CONFIG = { namespace: 'organization', dateFields: ['modified', 'created'], - defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + defaulkntParams: { page: 1, page_size: 5, order_by: 'name' }, integerFields: ['page', 'page_size'], }; @@ -20,7 +20,7 @@ describe('', () => { test('it triggers the expected callbacks', () => { const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, + { name: 'Name', key: 'name', isDefault: true } ]; const searchBtn = 'button[aria-label="Search submit button"]'; @@ -31,7 +31,6 @@ describe('', () => { search = mountWithContexts( @@ -47,13 +46,12 @@ describe('', () => { test('handleDropdownToggle properly updates state', async () => { const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, + { name: 'Name', key: 'name', isDefault: true } ]; const onSearch = jest.fn(); const wrapper = mountWithContexts( @@ -65,19 +63,13 @@ describe('', () => { 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( diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index cc53ebdcce..5b9ba6f523 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,6 +1,7 @@ import React 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, @@ -19,6 +20,11 @@ import { import styled from 'styled-components'; +import { + parseQueryString +} from '@util/qs'; +import { SortColumns, QSConfig } from '@types'; + const Dropdown = styled(PFDropdown)` &&& { > button { @@ -68,8 +74,31 @@ 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.filter(field => field === sortKey).length) { + isNumeric = true; + } else { + isNumeric = false; + } + this.state = { isSortDropdownOpen: false, + sortKey, + sortOrder, + isNumeric }; this.handleDropdownToggle = this.handleDropdownToggle.bind(this); @@ -82,34 +111,44 @@ 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( + const [{ key: sortKey }] = columns.filter( ({ name }) => name === innerText ); - this.setState({ isSortDropdownOpen: false }); - onSort(searchKey, sortOrder); + let isNumeric; + + if (qsConfig.integerFields.filter(field => field === sortKey).length) { + 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 }) => ( {name} @@ -168,16 +207,13 @@ class Sort extends React.Component { } Sort.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSort: PropTypes.func, - sortOrder: PropTypes.string, - sortedColumnKey: PropTypes.string, + qsConfig: QSConfig.isRequired, + columns: SortColumns.isRequired, + onSort: PropTypes.func }; Sort.defaultProps = { - onSort: null, - sortOrder: 'ascending', - sortedColumnKey: 'name', + onSort: null }; -export default withI18n()(Sort); +export default withI18n()(withRouter(Sort)); diff --git a/awx/ui_next/src/components/Sort/Sort.test.jsx b/awx/ui_next/src/components/Sort/Sort.test.jsx index 0e0208cd16..10bd4a4bc4 100644 --- a/awx/ui_next/src/components/Sort/Sort.test.jsx +++ b/awx/ui_next/src/components/Sort/Sort.test.jsx @@ -12,8 +12,17 @@ describe('', () => { }); 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"]'; @@ -22,8 +31,7 @@ describe('', () => { const wrapper = mountWithContexts( @@ -36,20 +44,33 @@ describe('', () => { }); 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( ).find('Sort'); @@ -60,20 +81,33 @@ describe('', () => { }); 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( ).find('Sort'); @@ -84,20 +118,33 @@ describe('', () => { }); 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( ).find('Sort'); @@ -107,20 +154,33 @@ describe('', () => { }); 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( ).find('Sort'); @@ -135,18 +195,38 @@ describe('', () => { const downAlphaIconSelector = 'SortAlphaDownIcon'; const upAlphaIconSelector = 'SortAlphaUpIcon'; + 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', isSortable: true, isNumeric: true }, + { name: 'ID', key: 'id' }, ]; const alphaColumns = [ - { name: 'Name', key: 'name', isSortable: true, isNumeric: false }, + { name: 'Name', key: 'name' }, ]; const onSort = jest.fn(); sort = mountWithContexts( @@ -157,8 +237,7 @@ describe('', () => { sort = mountWithContexts( @@ -169,8 +248,7 @@ describe('', () => { sort = mountWithContexts( @@ -181,8 +259,7 @@ describe('', () => { sort = mountWithContexts( diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index f907b78be9..c9b3958e3b 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -190,24 +190,33 @@ class HostsList extends Component { pluralizedItemName={i18n._(t`Hosts`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index 5b43ce0e04..d0ffa22023 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -174,24 +174,33 @@ class InventoriesList extends Component { pluralizedItemName={i18n._(t`Inventories`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.jsx index ad69ab8c18..0e732a4b61 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.jsx @@ -163,18 +163,25 @@ class JobList extends Component { pluralizedItemName="Jobs" qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Finished`), + key: 'finished', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Finished`), key: 'finished', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index cba609f3aa..a5a4462be7 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -119,24 +119,33 @@ function OrganizationsList({ i18n }) { pluralizedItemName="Organizations" qsConfig={QS_CONFIG} onRowClick={handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index fb6ed4357c..ab59019e18 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -156,24 +156,33 @@ class ProjectsList extends Component { pluralizedItemName={i18n._(t`Projects`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx index 7d9ff71e26..5637962520 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -154,24 +154,33 @@ class TeamsList extends Component { pluralizedItemName={i18n._(t`Teams`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 4516d44497..601d542ddb 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -214,24 +214,33 @@ class TemplatesList extends Component { pluralizedItemName={i18n._(t`Templates`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Modified`), key: 'modified', - isSortable: true, - isNumeric: true, }, { name: i18n._(t`Created`), key: 'created', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx index 121d9ea6c3..a25d727b79 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx @@ -154,24 +154,34 @@ class UsersList extends Component { pluralizedItemName="Users" qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + 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', + isDefault: 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 => ( diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 0b6542f3ac..002f6f5a46 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -249,3 +249,18 @@ export const Group = shape({ inventory: number, variables: string, }); + +export const SearchColumns = arrayOf( + shape({ + name: string.isRequired, + key: string.isRequired, + isDefault: bool, + }) +); + +export const SortColumns = arrayOf( + shape({ + name: string.isRequired, + key: string.isRequired, + }) +); diff --git a/awx/ui_next/src/util/qs.js b/awx/ui_next/src/util/qs.js index e7b38bad36..b029e62ecc 100644 --- a/awx/ui_next/src/util/qs.js +++ b/awx/ui_next/src/util/qs.js @@ -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 (!Object.keys(defaultParams).filter(key => key === 'order_by').length) { + defaultParams.order_by = 'name'; + } return { namespace, defaultParams, diff --git a/awx/ui_next/src/util/qs.test.js b/awx/ui_next/src/util/qs.test.js index c50ef8d217..d03d38baaa 100644 --- a/awx/ui_next/src/util/qs.test.js +++ b/awx/ui_next/src/util/qs.test.js @@ -121,6 +121,18 @@ 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 +144,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'], }); From c69d4970935d641122533bf4833104eb18191ed2 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 10 Dec 2019 13:11:49 -0500 Subject: [PATCH 03/18] remove debugger statement --- awx/ui_next/src/components/AddRole/SelectResourceStep.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx index 1783f9b224..75a35431ac 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx @@ -51,8 +51,6 @@ class SelectResourceStep extends React.Component { const { data } = await onSearch(queryParams); const { count, results } = data; - debugger; - this.setState({ resources: results, count, From a31661ce08169a05acd532d4e13cee128b7e4885 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 10 Dec 2019 13:12:19 -0500 Subject: [PATCH 04/18] utilize new DataToolbar experimental patternfly components --- .../DataListToolbar/DataListToolbar.jsx | 173 +++++--------- .../src/components/FilterTags/FilterTags.jsx | 80 +++---- .../src/components/ListHeader/ListHeader.jsx | 53 ++--- awx/ui_next/src/components/Search/Search.jsx | 222 +++++++++--------- awx/ui_next/src/components/Sort/Sort.jsx | 85 ++----- .../VerticalSeparator/VerticalSeparator.jsx | 2 +- 6 files changed, 245 insertions(+), 370 deletions(-) diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 7248dc0c51..31a1184cc9 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -4,73 +4,16 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Checkbox, - Toolbar as PFToolbar, - ToolbarGroup as PFToolbarGroup, - ToolbarItem, } from '@patternfly/react-core'; import styled from 'styled-components'; +import { SearchIcon } from '@patternfly/react-icons'; +import { DataToolbarGroup, DataToolbarToggleGroup, DataToolbarItem } from '@patternfly/react-core/dist/esm/experimental'; import ExpandCollapse from '../ExpandCollapse'; import Search from '../Search'; import Sort from '../Sort'; -import VerticalSeparator from '../VerticalSeparator'; import { SearchColumns, SortColumns, 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%')}; - } -`; - const AdditionalControlsWrapper = styled.div` display: flex; flex-grow: 1; @@ -82,6 +25,18 @@ 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 { @@ -90,9 +45,9 @@ class DataListToolbar extends React.Component { showSelectAll, isAllSelected, isCompact, - fillWidth, onSort, onSearch, + onRemove, onCompact, onExpand, onSelectAll, @@ -103,58 +58,58 @@ class DataListToolbar extends React.Component { const showExpandCollapse = onCompact && onExpand; return ( - - - - {showSelectAll && ( - - - - - - - )} - - + {showSelectAll && ( + + + - - - - - - - - {showExpandCollapse && ( - - - - - - {additionalControls && } - - )} + + + + )} + } breakpoint="xl"> + + + + + + + + + {showExpandCollapse && ( + + + + + + )} + + + {additionalControls} - - - + + + ); } } @@ -166,7 +121,6 @@ DataListToolbar.propTypes = { showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, isCompact: PropTypes.bool, - fillWidth: PropTypes.bool, onCompact: PropTypes.func, onExpand: PropTypes.func, onSearch: PropTypes.func, @@ -179,7 +133,6 @@ DataListToolbar.defaultProps = { showSelectAll: false, isAllSelected: false, isCompact: false, - fillWidth: false, onCompact: null, onExpand: null, onSearch: null, diff --git a/awx/ui_next/src/components/FilterTags/FilterTags.jsx b/awx/ui_next/src/components/FilterTags/FilterTags.jsx index 749c326256..b088d10b8d 100644 --- a/awx/ui_next/src/components/FilterTags/FilterTags.jsx +++ b/awx/ui_next/src/components/FilterTags/FilterTags.jsx @@ -1,21 +1,13 @@ -import React from 'react'; +import React, { Fragment } 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 { DataToolbarGroup, DataToolbarItem } from '@patternfly/react-core/dist/esm/experimental'; import { parseQueryString } from '@util/qs'; -import { ChipGroup as _ChipGroup, Chip } from '@components/Chip'; +import { Button, Chip, ChipGroup, ChipGroupToolbarItem } from '@patternfly/react-core'; 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; `; @@ -24,12 +16,6 @@ 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); @@ -45,7 +31,7 @@ const FilterTags = ({ onRemoveAll, }) => { const queryParams = parseQueryString(qsConfig, location.search); - const queryParamsArr = []; + const queryParamsByKey = {}; const nonDefaultParams = filterDefaultParams( Object.keys(queryParams), qsConfig @@ -56,45 +42,45 @@ const FilterTags = ({ .split('_') .map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) .join(' '); + queryParamsByKey[key] = { label, tags: [] }; if (Array.isArray(queryParams[key])) { queryParams[key].forEach(val => - queryParamsArr.push({ key, value: val, label }) + queryParamsByKey[key].tags.push(val) ); } else { - queryParamsArr.push({ key, value: queryParams[key], label }); + queryParamsByKey[key].tags.push(queryParams[key]); } }); return ( - queryParamsArr.length > 0 && ( - - {i18n._(t`${itemCount} results`)} - - {i18n._(t`Active Filters:`)} - - {queryParamsArr.map(({ key, label, value }) => ( - onRemove(key, value)} - > - {label}: {value} - - ))} -
- -
-
-
+ + + ) ); }; diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx index 93d52df4c9..e88505cb5f 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -2,8 +2,8 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import styled from 'styled-components'; +import { DataToolbar, DataToolbarContent } from '@patternfly/react-core/dist/esm/experimental'; import DataListToolbar from '@components/DataListToolbar'; -import FilterTags from '@components/FilterTags'; import { encodeNonDefaultQueryString, @@ -84,33 +84,32 @@ class ListHeader extends React.Component { return ( {isEmpty ? ( - - - {emptyStateControls} - - - + + + + {emptyStateControls} + + + ) : ( - - {renderToolbar({ - searchColumns, - sortColumns, - onSearch: this.handleSearch, - onSort: this.handleSort, - qsConfig, - })} - - + + + {renderToolbar({ + searchColumns, + sortColumns, + onSearch: this.handleSearch, + onSort: this.handleSort, + onRemove: this.handleRemove, + qsConfig, + })} + + )} ); diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 6dda72a866..528b428cc2 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -2,80 +2,33 @@ import React 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, + TextInput, } from '@patternfly/react-core'; +import { + DataToolbarGroup, + DataToolbarItem, + DataToolbarFilter +} from '@patternfly/react-core/dist/esm/experimental'; import { SearchIcon } from '@patternfly/react-icons'; - +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 { @@ -94,6 +47,7 @@ class Search extends React.Component { this.handleDropdownToggle = this.handleDropdownToggle.bind(this); this.handleDropdownSelect = this.handleDropdownSelect.bind(this); this.handleSearch = this.handleSearch.bind(this); + this.handleTextKeyDown = this.handleTextKeyDown.bind(this); } handleDropdownToggle(isSearchDropdownOpen) { @@ -134,9 +88,15 @@ class Search extends React.Component { this.setState({ searchValue }); } + handleTextKeyDown(e) { + if (e.key && e.key === 'Enter') { + this.handleSearch(e); + } + } + render() { const { up } = DropdownPosition; - const { columns, i18n } = this.props; + const { columns, i18n, onRemove, qsConfig, location } = this.props; const { isSearchDropdownOpen, searchKey, searchValue } = this.state; const { name: searchColumnName } = columns.find( ({ key }) => key === searchKey @@ -150,65 +110,95 @@ class Search extends React.Component { )); + 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', ''); + const label = key + .replace('__icontains', '') + .split('_') + .map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(' '); + + queryParamsByKey[columnKey] = { key, label, chips: [] }; + + if (Array.isArray(queryParams[key])) { + queryParams[key].forEach(val => + queryParamsByKey[columnKey].chips.push(val) + ); + } else { + queryParamsByKey[columnKey].chips.push(queryParams[key]); + } + }); + + return queryParamsByKey; + } + + const chipsByKey = getChipsByKey(); + return ( -
-
+ + {searchDropdownItems.length > 0 ? ( - - {i18n._(t`Search key dropdown`)} - + + {searchColumnName} + } - > - - {searchColumnName} - - } - dropdownItems={searchDropdownItems} - /> - - ) : ( - {searchColumnName} - )} - - {i18n._(t`Search value text input`)} - - } - style={{ width: '100%' }} - suppressClassNameWarning - > + isOpen={isSearchDropdownOpen} + dropdownItems={searchDropdownItems} + style={{ width: '100%' }} + />) : ({searchColumnName})} + + {columns.map(({key}) => ( { onRemove(chipsByKey[key].key, val) }} + categoryName={chipsByKey[key] ? chipsByKey[key].label : key} + key={key} + showToolbarItem={searchKey === key} + > + - - -
-
+ + + ))} + ); } } @@ -216,11 +206,13 @@ class Search extends React.Component { Search.propTypes = { qsConfig: QSConfig.isRequired, columns: SearchColumns.isRequired, - onSearch: PropTypes.func + onSearch: PropTypes.func, + onRemove: PropTypes.func }; Search.defaultProps = { onSearch: null, + onRemove: null }; -export default withI18n()(Search); +export default withI18n()(withRouter(Search)); diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index 5b9ba6f523..fd12908917 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,15 +1,16 @@ -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, @@ -18,58 +19,11 @@ import { SortNumericUpIcon, } from '@patternfly/react-icons'; -import styled from 'styled-components'; - import { parseQueryString } from '@util/qs'; import { SortColumns, QSConfig } from '@types'; -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); -`; - class Sort extends React.Component { constructor(props) { super(props); @@ -165,12 +119,10 @@ class Sort extends React.Component { } return ( - + {sortDropdownItems.length > 0 && ( - - {i18n._(t`Sort By`)} + - + + )} - {i18n._(t`Reverse Sort Order`)}} - position="top" - > - - - - - - - + ); } } diff --git a/awx/ui_next/src/components/VerticalSeparator/VerticalSeparator.jsx b/awx/ui_next/src/components/VerticalSeparator/VerticalSeparator.jsx index ebe53e5ada..94679dadf7 100644 --- a/awx/ui_next/src/components/VerticalSeparator/VerticalSeparator.jsx +++ b/awx/ui_next/src/components/VerticalSeparator/VerticalSeparator.jsx @@ -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; From 6edd879a43d509afd67a0ea51ba74cd88a50545b Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 16 Dec 2019 15:54:55 -0500 Subject: [PATCH 05/18] add support for number, boolean, and option-based searches --- .../DataListToolbar/DataListToolbar.jsx | 4 + .../src/components/FilterTags/FilterTags.jsx | 88 ------------------ .../components/FilterTags/FilterTags.test.jsx | 51 ----------- .../src/components/FilterTags/index.js | 1 - .../src/components/ListHeader/ListHeader.jsx | 13 ++- .../components/Lookup/shared/OptionsList.jsx | 6 +- awx/ui_next/src/components/Search/Search.jsx | 89 +++++++++++++++---- 7 files changed, 94 insertions(+), 158 deletions(-) delete mode 100644 awx/ui_next/src/components/FilterTags/FilterTags.jsx delete mode 100644 awx/ui_next/src/components/FilterTags/FilterTags.test.jsx delete mode 100644 awx/ui_next/src/components/FilterTags/index.js diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 31a1184cc9..331fcacafc 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -47,6 +47,7 @@ class DataListToolbar extends React.Component { isCompact, onSort, onSearch, + onReplaceSearch, onRemove, onCompact, onExpand, @@ -78,6 +79,7 @@ class DataListToolbar extends React.Component { qsConfig={qsConfig} columns={searchColumns} onSearch={onSearch} + onReplaceSearch={onReplaceSearch} onRemove={onRemove} /> @@ -124,6 +126,7 @@ DataListToolbar.propTypes = { onCompact: PropTypes.func, onExpand: PropTypes.func, onSearch: PropTypes.func, + onReplaceSearch: PropTypes.func, onSelectAll: PropTypes.func, onSort: PropTypes.func, additionalControls: PropTypes.arrayOf(PropTypes.node), @@ -136,6 +139,7 @@ DataListToolbar.defaultProps = { onCompact: null, onExpand: null, onSearch: null, + onReplaceSearch: null, onSelectAll: null, onSort: null, additionalControls: [], diff --git a/awx/ui_next/src/components/FilterTags/FilterTags.jsx b/awx/ui_next/src/components/FilterTags/FilterTags.jsx deleted file mode 100644 index b088d10b8d..0000000000 --- a/awx/ui_next/src/components/FilterTags/FilterTags.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { Fragment } from 'react'; -import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import styled from 'styled-components'; -import { DataToolbarGroup, DataToolbarItem } from '@patternfly/react-core/dist/esm/experimental'; -import { parseQueryString } from '@util/qs'; -import { Button, Chip, ChipGroup, ChipGroupToolbarItem } from '@patternfly/react-core'; -import VerticalSeparator from '@components/VerticalSeparator'; - -const ResultCount = styled.span` - font-weight: bold; -`; - -const FilterLabel = styled.span` - padding-right: 20px; -`; - -// 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 queryParamsByKey = {}; - 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(' '); - queryParamsByKey[key] = { label, tags: [] }; - - if (Array.isArray(queryParams[key])) { - queryParams[key].forEach(val => - queryParamsByKey[key].tags.push(val) - ); - } else { - queryParamsByKey[key].tags.push(queryParams[key]); - } - }); - - return ( - Object.keys(queryParamsByKey).length > 0 && ( - - - {i18n._(t`${itemCount} results`)} - - - {i18n._(t`Active Filters:`)} - - {Object.keys(queryParamsByKey).map(key => ( - - - {queryParamsByKey[key].tags.map(chip => ( - onRemove(key, chip)}> - {chip} - - ))} - - - ))} - - - - - - - ) - ); -}; - -export default withI18n()(withRouter(FilterTags)); diff --git a/awx/ui_next/src/components/FilterTags/FilterTags.test.jsx b/awx/ui_next/src/components/FilterTags/FilterTags.test.jsx deleted file mode 100644 index 8bfd6642d7..0000000000 --- a/awx/ui_next/src/components/FilterTags/FilterTags.test.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import FilterTags from './FilterTags'; - -describe('', () => { - 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( - - ); - 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( - , - { - 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(); - }); -}); diff --git a/awx/ui_next/src/components/FilterTags/index.js b/awx/ui_next/src/components/FilterTags/index.js deleted file mode 100644 index 19a1fa21b9..0000000000 --- a/awx/ui_next/src/components/FilterTags/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './FilterTags'; diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx index e88505cb5f..54ca0ddca5 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -30,6 +30,7 @@ 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); @@ -41,9 +42,18 @@ class ListHeader extends React.Component { this.pushHistoryState(mergeParams(oldParams, { [key]: value })); } - handleRemove(key, value) { + handleReplaceSearch(key, value) { const { location, qsConfig } = this.props; const oldParams = parseQueryString(qsConfig, location.search); + this.pushHistoryState(replaceParams(oldParams, { [key]: value })); + } + + handleRemove(key, value) { + const { location, qsConfig } = this.props; + 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 })); } @@ -104,6 +114,7 @@ class ListHeader extends React.Component { searchColumns, sortColumns, onSearch: this.handleSearch, + onReplaceSearch: this.handleReplaceSearch, onSort: this.handleSort, onRemove: this.handleRemove, qsConfig, diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index ae3451f9ff..fa9eb8f67c 100644 --- a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -80,8 +80,8 @@ OptionsList.propTypes = { value: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired, optionCount: number.isRequired, - searchColumns: SearchColumns.isRequired, - sortColumns: SortColumns.isRequired, + searchColumns: SearchColumns, + sortColumns: SortColumns, multiple: bool, qsConfig: QSConfig.isRequired, selectItem: func.isRequired, @@ -91,6 +91,8 @@ OptionsList.propTypes = { OptionsList.defaultProps = { multiple: false, renderItemChip: null, + searchColumns: [], + sortColumns: [] }; export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 528b428cc2..a2a5a6a906 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -11,6 +11,9 @@ import { DropdownToggle, DropdownItem, InputGroup, + Select, + SelectOption, + SelectVariant, TextInput, } from '@patternfly/react-core'; import { @@ -41,6 +44,7 @@ class Search extends React.Component { isSearchDropdownOpen: false, searchKey: columns.find(col => col.isDefault).key, searchValue: '', + isFilterDropdownOpen: false }; this.handleSearchInputChange = this.handleSearchInputChange.bind(this); @@ -48,6 +52,9 @@ class Search extends React.Component { 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) { @@ -73,8 +80,6 @@ class Search extends React.Component { qsConfig.integerFields.filter(field => field === searchKey).length || qsConfig.dateFields.filter(field => field === searchKey).length; - // TODO: this will probably become more sophisticated, where date - // fields and string fields are passed to a formatter const actualSearchKey = isNonStringField ? searchKey : `${searchKey}__icontains`; @@ -94,10 +99,29 @@ class Search extends React.Component { } } + 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, onRemove, qsConfig, location } = this.props; - const { isSearchDropdownOpen, searchKey, searchValue } = this.state; + const { isSearchDropdownOpen, searchKey, searchValue, isFilterDropdownOpen } = this.state; const { name: searchColumnName } = columns.find( ({ key }) => key === searchKey ); @@ -129,21 +153,20 @@ class Search extends React.Component { nonDefaultParams.forEach(key => { const columnKey = key - .replace('__icontains', ''); - const label = key .replace('__icontains', '') - .split('_') - .map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) - .join(' '); + .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) + queryParamsByKey[columnKey].chips.push(val.toString()) ); } else { - queryParamsByKey[columnKey].chips.push(queryParams[key]); + queryParamsByKey[columnKey].chips.push(queryParams[key].toString()); } }); @@ -174,16 +197,52 @@ class Search extends React.Component { style={{ width: '100%' }} />) : ({searchColumnName})} - {columns.map(({key}) => ( ( { onRemove(chipsByKey[key].key, val) }} categoryName={chipsByKey[key] ? chipsByKey[key].label : key} key={key} showToolbarItem={searchKey === key} > - + {options && () || isBoolean && ( + + ) || ( + {/* TODO: add support for dates: + qsConfig.dateFields.filter(field => field === key).length && "date" */} field === key).length && "number" || "search"} aria-label={i18n._(t`Search text input`)} value={searchValue} onChange={this.handleSearchInputChange} @@ -196,7 +255,7 @@ class Search extends React.Component { > - + )} ))} ); From 51a6ba14f10471b3f6d5d60ff3e3a375f942dc48 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 17 Dec 2019 11:44:35 -0500 Subject: [PATCH 06/18] support 1 item in sortColumns --- awx/ui_next/src/components/Sort/Sort.jsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index fd12908917..9c6c67d49a 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -23,6 +23,15 @@ import { parseQueryString } from '@util/qs'; import { SortColumns, QSConfig } from '@types'; +import styled from 'styled-components'; + +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) { @@ -120,9 +129,9 @@ class Sort extends React.Component { return ( - {sortDropdownItems.length > 0 && ( + {sortedColumnName && ( - 0 && ( } dropdownItems={sortDropdownItems} - /> + />) || ( + {sortedColumnName} + )} - )} - ))} + ) : ( + {searchColumnName} + )} + + {columns.map(({ key, name, options, isBoolean }) => ( + { + onRemove(chipsByKey[key].key, val); + }} + categoryName={chipsByKey[key] ? chipsByKey[key].label : key} + key={key} + showToolbarItem={searchKey === key} + > + {(options && ( + + )) || + (isBoolean && ( + + )) || ( + + {/* TODO: add support for dates: + qsConfig.dateFields.filter(field => field === key).length && "date" */} + field === searchKey + ) && + 'number') || + 'search' + } + aria-label={i18n._(t`Search text input`)} + value={searchValue} + onChange={this.handleSearchInputChange} + onKeyDown={this.handleTextKeyDown} + /> + + + )} + + ))} ); } @@ -266,12 +298,12 @@ Search.propTypes = { qsConfig: QSConfig.isRequired, columns: SearchColumns.isRequired, onSearch: PropTypes.func, - onRemove: PropTypes.func + onRemove: PropTypes.func, }; Search.defaultProps = { onSearch: null, - onRemove: null + onRemove: null, }; export default withI18n()(withRouter(Search)); diff --git a/awx/ui_next/src/components/Search/Search.test.jsx b/awx/ui_next/src/components/Search/Search.test.jsx index c69cf76bd8..76a7981bfb 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -1,5 +1,8 @@ import React from 'react'; -import { DataToolbar, DataToolbarContent } from '@patternfly/react-core/dist/umd/experimental'; +import { + DataToolbar, + DataToolbarContent, +} from '@patternfly/react-core/dist/umd/experimental'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Search from './Search'; @@ -20,9 +23,7 @@ describe('', () => { }); test('it triggers the expected callbacks', () => { - const columns = [ - { name: 'Name', key: 'name', isDefault: 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"]'; @@ -30,16 +31,13 @@ describe('', () => { const onSearch = jest.fn(); search = mountWithContexts( - {}} collapseListedFiltersBreakpoint="md" > - + ); @@ -53,21 +51,16 @@ describe('', () => { }); test('handleDropdownToggle properly updates state', async () => { - const columns = [ - { name: 'Name', key: 'name', isDefault: true } - ]; + const columns = [{ name: 'Name', key: 'name', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( - {}} collapseListedFiltersBreakpoint="md" > - + ).find('Search'); @@ -83,16 +76,13 @@ describe('', () => { ]; const onSearch = jest.fn(); const wrapper = mountWithContexts( - {}} collapseListedFiltersBreakpoint="md" > - + ).find('Search'); diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index 78b2eb5e1d..700679fc2a 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -19,9 +19,7 @@ import { SortNumericUpIcon, } from '@patternfly/react-icons'; -import { - parseQueryString -} from '@util/qs'; +import { parseQueryString } from '@util/qs'; import { SortColumns, QSConfig } from '@types'; import styled from 'styled-components'; @@ -51,7 +49,7 @@ class Sort extends React.Component { sortOrder = 'ascending'; } - if (qsConfig.integerFields.filter(field => field === sortKey).length) { + if (qsConfig.integerFields.find(field => field === sortKey)) { isNumeric = true; } else { isNumeric = false; @@ -61,7 +59,7 @@ class Sort extends React.Component { isSortDropdownOpen: false, sortKey, sortOrder, - isNumeric + isNumeric, }; this.handleDropdownToggle = this.handleDropdownToggle.bind(this); @@ -78,13 +76,11 @@ class Sort extends React.Component { const { sortOrder } = this.state; const { innerText } = target; - const [{ key: sortKey }] = columns.filter( - ({ name }) => name === innerText - ); + const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); let isNumeric; - if (qsConfig.integerFields.filter(field => field === sortKey).length) { + if (qsConfig.integerFields.find(field => field === sortKey)) { isNumeric = true; } else { isNumeric = false; @@ -131,23 +127,23 @@ class Sort extends React.Component { {sortedColumnName && ( - {sortDropdownItems.length > 0 && ( - {sortedColumnName} - - } - dropdownItems={sortDropdownItems} - />) || ( - {sortedColumnName} - )} + {(sortDropdownItems.length > 0 && ( + + {sortedColumnName} + + } + dropdownItems={sortDropdownItems} + /> + )) || {sortedColumnName}}