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. diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 596b3bbe97..2a1189dfb6 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -142,21 +142,57 @@ class AddResourceRole extends React.Component { } = this.state; const { onClose, roles, i18n } = this.props; - const userColumns = [ + const userSearchColumns = [ { name: i18n._(t`Username`), key: 'username', - isSortable: true, - isSearchable: true, + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', }, ]; - const teamColumns = [ + const userSortColumns = [ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]; + + const teamSearchColumns = [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]; + + const teamSortColumns = [ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, ]; @@ -207,7 +243,8 @@ class AddResourceRole extends React.Component { {selectedResource === 'users' && ( col.key === 'name').length + ? 'name' + : 'username' + }`, }); } @@ -69,7 +74,8 @@ class SelectResourceStep extends React.Component { const { isInitialized, isLoading, count, error, resources } = this.state; const { - columns, + searchColumns, + sortColumns, displayKey, onRowClick, selectedLabel, @@ -99,8 +105,9 @@ class SelectResourceStep extends React.Component { items={resources} itemCount={count} qsConfig={this.qsConfig} - toolbarColumns={columns} onRowClick={onRowClick} + toolbarSearchColumns={searchColumns} + toolbarSortColumns={sortColumns} renderItem={item => ( i.id === item.id)} @@ -123,21 +130,22 @@ class SelectResourceStep extends React.Component { } SelectResourceStep.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, + searchColumns: SearchColumns, + sortColumns: SortColumns, displayKey: PropTypes.string, onRowClick: PropTypes.func, onSearch: PropTypes.func.isRequired, selectedLabel: PropTypes.string, selectedResourceRows: PropTypes.arrayOf(PropTypes.object), - sortedColumnKey: PropTypes.string, }; SelectResourceStep.defaultProps = { + searchColumns: null, + sortColumns: null, displayKey: 'name', onRowClick: () => {}, selectedLabel: null, selectedResourceRows: [], - sortedColumnKey: 'name', }; export { SelectResourceStep as _SelectResourceStep }; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index 3d32abef06..9d25292c96 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..9bded82e19 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -2,75 +2,21 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Checkbox, - Toolbar as PFToolbar, - ToolbarGroup as PFToolbarGroup, - ToolbarItem, -} from '@patternfly/react-core'; - +import { Checkbox } from '@patternfly/react-core'; import styled from 'styled-components'; +import { SearchIcon } from '@patternfly/react-icons'; +import { + DataToolbar, + DataToolbarContent, + DataToolbarGroup, + DataToolbarToggleGroup, + DataToolbarItem, +} from '@patternfly/react-core/dist/umd/experimental'; import ExpandCollapse from '../ExpandCollapse'; import Search from '../Search'; import Sort from '../Sort'; -import VerticalSeparator from '../VerticalSeparator'; -import { QSConfig } from '@types'; - -const AWXToolbar = styled.div` - --awx-toolbar--BackgroundColor: var(--pf-global--BackgroundColor--light-100); - --awx-toolbar--BorderColor: #ebebeb; - --awx-toolbar--BorderWidth: var(--pf-global--BorderWidth--sm); - - --pf-global--target-size--MinHeight: 0; - --pf-global--target-size--MinWidth: 0; - --pf-global--FontSize--md: 14px; - - border-bottom: var(--awx-toolbar--BorderWidth) solid - var(--awx-toolbar--BorderColor); - background-color: var(--awx-toolbar--BackgroundColor); - display: flex; - min-height: 70px; - flex-grow: 1; -`; - -const Toolbar = styled(PFToolbar)` - flex-grow: 1; - margin-left: 20px; - margin-right: 20px; -`; - -const ToolbarGroup = styled(PFToolbarGroup)` - &&& { - margin: 0; - } -`; - -const ColumnLeft = styled.div` - display: flex; - flex-basis: ${props => (props.fillWidth ? 'auto' : '100%')}; - flex-grow: ${props => (props.fillWidth ? '1' : '0')}; - justify-content: flex-start; - align-items: center; - padding: 10px 0 8px 0; - - @media screen and (min-width: 980px) { - flex-basis: ${props => (props.fillWidth ? 'auto' : '50%')}; - } -`; - -const ColumnRight = styled.div` - display: flex; - flex-basis: ${props => (props.fillWidth ? 'auto' : '100%')}; - flex-grow: 0; - justify-content: flex-start; - align-items: center; - padding: 8px 0 10px 0; - - @media screen and (min-width: 980px) { - flex-basis: ${props => (props.fillWidth ? 'auto' : '50%')}; - } -`; +import { SearchColumns, SortColumns, QSConfig } from '@types'; const AdditionalControlsWrapper = styled.div` display: flex; @@ -83,21 +29,34 @@ const AdditionalControlsWrapper = styled.div` } `; +const AdditionalControlsDataToolbarGroup = styled(DataToolbarGroup)` + margin-left: auto; + margin-right: 0 !important; +`; + +const DataToolbarSeparator = styled(DataToolbarItem)` + width: 1px !important; + height: 30px !important; + margin-left: 3px !important; + margin-right: 10px !important; +`; + class DataListToolbar extends React.Component { render() { const { - columns, + clearAllFilters, + searchColumns, + sortColumns, showSelectAll, isAllSelected, isCompact, - fillWidth, onSort, onSearch, + onReplaceSearch, + onRemove, onCompact, onExpand, onSelectAll, - sortOrder, - sortedColumnKey, additionalControls, i18n, qsConfig, @@ -105,93 +64,93 @@ class DataListToolbar extends React.Component { const showExpandCollapse = onCompact && onExpand; return ( - - - - {showSelectAll && ( - - - - - - - )} - + + + {showSelectAll && ( + + + + + + + )} + } breakpoint="xl"> + - - - - - - - + + + + + + {showExpandCollapse && ( - - + - - {additionalControls && } + )} - - {additionalControls} - - - - + + + + + {additionalControls} + + + + + ); } } DataListToolbar.propTypes = { + clearAllFilters: PropTypes.func, qsConfig: QSConfig.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, + searchColumns: SearchColumns.isRequired, + sortColumns: SortColumns.isRequired, showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, isCompact: PropTypes.bool, - fillWidth: PropTypes.bool, onCompact: PropTypes.func, onExpand: PropTypes.func, onSearch: PropTypes.func, + onReplaceSearch: PropTypes.func, onSelectAll: PropTypes.func, onSort: PropTypes.func, - sortOrder: PropTypes.string, - sortedColumnKey: PropTypes.string, additionalControls: PropTypes.arrayOf(PropTypes.node), }; DataListToolbar.defaultProps = { + clearAllFilters: null, showSelectAll: false, isAllSelected: false, isCompact: false, - fillWidth: false, onCompact: null, onExpand: null, onSearch: null, + onReplaceSearch: null, onSelectAll: null, onSort: null, - sortOrder: 'ascending', - sortedColumnKey: 'name', additionalControls: [], }; diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx index 376cbc8efa..b8b83ee6e2 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx @@ -20,14 +20,13 @@ describe('', () => { }); const onSearch = jest.fn(); + const onReplaceSearch = jest.fn(); const onSort = jest.fn(); const onSelectAll = jest.fn(); test('it triggers the expected callbacks', () => { - const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, - ]; - + const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const sortColumns = [{ name: 'Name', key: 'name' }]; const search = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; const selectAll = 'input[aria-label="Select all"]'; @@ -38,10 +37,10 @@ describe('', () => { qsConfig={QS_CONFIG} isAllSelected={false} showExpandCollapse - sortedColumnKey="name" - sortOrder="ascending" - columns={columns} + searchColumns={searchColumns} + sortColumns={sortColumns} onSearch={onSearch} + onReplaceSearch={onReplaceSearch} onSort={onSort} onSelectAll={onSelectAll} showSelectAll @@ -74,19 +73,28 @@ describe('', () => { 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 +114,9 @@ describe('', () => { searchDropdownItems.at(0).simulate('click', mockedSortEvent); toolbar = mountWithContexts( ); @@ -145,77 +152,104 @@ describe('', () => { }); test('it displays correct sort icon', () => { - const downNumericIconSelector = 'SortNumericDownIcon'; - const upNumericIconSelector = 'SortNumericUpIcon'; - const downAlphaIconSelector = 'SortAlphaDownIcon'; - const upAlphaIconSelector = 'SortAlphaUpIcon'; + const NUM_QS_CONFIG = { + namespace: 'organization', + dateFields: ['modified', 'created'], + defaultParams: { page: 1, page_size: 5, order_by: 'id' }, + integerFields: ['page', 'page_size', 'id'], + }; - const numericColumns = [ - { name: 'ID', key: 'id', isSortable: true, isNumeric: true }, - ]; - const alphaColumns = [ - { name: 'Name', key: 'name', isSortable: true, isNumeric: false }, + const NUM_DESC_QS_CONFIG = { + namespace: 'organization', + dateFields: ['modified', 'created'], + defaultParams: { page: 1, page_size: 5, order_by: '-id' }, + integerFields: ['page', 'page_size', 'id'], + }; + + const ALPH_QS_CONFIG = { + namespace: 'organization', + dateFields: ['modified', 'created'], + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size', 'id'], + }; + + const ALPH_DESC_QS_CONFIG = { + namespace: 'organization', + dateFields: ['modified', 'created'], + defaultParams: { page: 1, page_size: 5, order_by: '-name' }, + integerFields: ['page', 'page_size', 'id'], + }; + + const forwardNumericIconSelector = 'SortNumericDownIcon'; + const reverseNumericIconSelector = 'SortNumericDownAltIcon'; + const forwardAlphaIconSelector = 'SortAlphaDownIcon'; + const reverseAlphaIconSelector = 'SortAlphaDownAltIcon'; + + const numericColumns = [{ name: 'ID', key: 'id' }]; + + const alphaColumns = [{ name: 'Name', key: 'name' }]; + + const searchColumns = [ + { name: 'Name', key: 'name', isDefault: true }, + { name: 'ID', key: 'id' }, ]; toolbar = mountWithContexts( ); - const downNumericIcon = toolbar.find(downNumericIconSelector); - expect(downNumericIcon.length).toBe(1); + const reverseNumericIcon = toolbar.find(reverseNumericIconSelector); + expect(reverseNumericIcon.length).toBe(1); toolbar = mountWithContexts( ); - const upNumericIcon = toolbar.find(upNumericIconSelector); - expect(upNumericIcon.length).toBe(1); + const forwardNumericIcon = toolbar.find(forwardNumericIconSelector); + expect(forwardNumericIcon.length).toBe(1); toolbar = mountWithContexts( ); - const downAlphaIcon = toolbar.find(downAlphaIconSelector); - expect(downAlphaIcon.length).toBe(1); + const reverseAlphaIcon = toolbar.find(reverseAlphaIconSelector); + expect(reverseAlphaIcon.length).toBe(1); toolbar = mountWithContexts( ); - const upAlphaIcon = toolbar.find(upAlphaIconSelector); - expect(upAlphaIcon.length).toBe(1); + const forwardAlphaIcon = toolbar.find(forwardAlphaIconSelector); + expect(forwardAlphaIcon.length).toBe(1); }); test('should render additionalControls', () => { - const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, - ]; + const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const sortColumns = [{ name: 'Name', key: 'name' }]; toolbar = mountWithContexts( ', () => { }); 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( { - const defaultParamsKeys = Object.keys(config.defaultParams); - return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); -}; - -const FilterTags = ({ - i18n, - itemCount, - qsConfig, - location, - onRemove, - onRemoveAll, -}) => { - const queryParams = parseQueryString(qsConfig, location.search); - const queryParamsArr = []; - const nonDefaultParams = filterDefaultParams( - Object.keys(queryParams), - qsConfig - ); - nonDefaultParams.forEach(key => { - const label = key - .replace('__icontains', '') - .split('_') - .map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) - .join(' '); - - if (Array.isArray(queryParams[key])) { - queryParams[key].forEach(val => - queryParamsArr.push({ key, value: val, label }) - ); - } else { - queryParamsArr.push({ key, value: queryParams[key], label }); - } - }); - - return ( - queryParamsArr.length > 0 && ( - - {i18n._(t`${itemCount} results`)} - - {i18n._(t`Active Filters:`)} - - {queryParamsArr.map(({ key, label, value }) => ( - onRemove(key, value)} - > - {label}: {value} - - ))} -
- -
-
-
- ) - ); -}; - -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 ad9cab62ce..3eb82cbedf 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -1,10 +1,12 @@ import React, { Fragment } from 'react'; -import PropTypes, { arrayOf, shape, string, bool } from 'prop-types'; +import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import styled from 'styled-components'; - +import { + DataToolbar, + DataToolbarContent, +} from '@patternfly/react-core/dist/umd/experimental'; import DataListToolbar from '@components/DataListToolbar'; -import FilterTags from '@components/FilterTags'; import { encodeNonDefaultQueryString, @@ -13,7 +15,7 @@ import { replaceParams, removeParams, } from '@util/qs'; -import { QSConfig } from '@types'; +import { QSConfig, SearchColumns, SortColumns } from '@types'; const EmptyStateControlsWrapper = styled.div` display: flex; @@ -31,29 +33,34 @@ class ListHeader extends React.Component { super(props); this.handleSearch = this.handleSearch.bind(this); + this.handleReplaceSearch = this.handleReplaceSearch.bind(this); this.handleSort = this.handleSort.bind(this); this.handleRemove = this.handleRemove.bind(this); this.handleRemoveAll = this.handleRemoveAll.bind(this); } - getSortOrder() { - const { qsConfig, location } = this.props; - const queryParams = parseQueryString(qsConfig, location.search); - if (queryParams.order_by && queryParams.order_by.startsWith('-')) { - return [queryParams.order_by.substr(1), 'descending']; - } - return [queryParams.order_by, 'ascending']; - } - handleSearch(key, value) { + const { location, qsConfig } = this.props; + let params = parseQueryString(qsConfig, location.search); + params = mergeParams(params, { [key]: value }); + params = replaceParams(params, { page: 1 }); + this.pushHistoryState(params); + } + + handleReplaceSearch(key, value) { const { location, qsConfig } = this.props; const oldParams = parseQueryString(qsConfig, location.search); - this.pushHistoryState(mergeParams(oldParams, { [key]: value })); + this.pushHistoryState(replaceParams(oldParams, { [key]: value })); } handleRemove(key, value) { const { location, qsConfig } = this.props; - const oldParams = parseQueryString(qsConfig, location.search); + let oldParams = parseQueryString(qsConfig, location.search); + if (parseInt(value, 10)) { + oldParams = removeParams(qsConfig, oldParams, { + [key]: parseInt(value, 10), + }); + } this.pushHistoryState(removeParams(qsConfig, oldParams, { [key]: value })); } @@ -83,44 +90,40 @@ class ListHeader extends React.Component { const { emptyStateControls, itemCount, - columns, + searchColumns, + sortColumns, renderToolbar, qsConfig, location, } = this.props; - const [orderBy, sortOrder] = this.getSortOrder(); const params = parseQueryString(qsConfig, location.search); const isEmpty = itemCount === 0 && Object.keys(params).length === 0; return ( {isEmpty ? ( - - - {emptyStateControls} - - - + + + + {emptyStateControls} + + + ) : ( {renderToolbar({ - sortedColumnKey: orderBy, - sortOrder, - columns, + searchColumns, + sortColumns, onSearch: this.handleSearch, + onReplaceSearch: this.handleReplaceSearch, onSort: this.handleSort, + onRemove: this.handleRemove, + clearAllFilters: this.handleRemoveAll, qsConfig, })} - )} @@ -131,14 +134,8 @@ class ListHeader extends React.Component { ListHeader.propTypes = { itemCount: PropTypes.number.isRequired, qsConfig: QSConfig.isRequired, - columns: arrayOf( - shape({ - name: string.isRequired, - key: string.isRequired, - isSortable: bool, - isSearchable: bool, - }) - ).isRequired, + searchColumns: SearchColumns.isRequired, + sortColumns: SortColumns.isRequired, renderToolbar: PropTypes.func, }; diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx index 9435f2e8b3..3392bea65e 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx @@ -1,13 +1,12 @@ import React from 'react'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; -import { sleep } from '@testUtils/testUtils'; import ListHeader from './ListHeader'; describe('ListHeader', () => { const qsConfig = { namespace: 'item', - defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, integerFields: [], }; const renderToolbarFn = jest.fn(); @@ -17,9 +16,8 @@ describe('ListHeader', () => { ); @@ -35,26 +33,16 @@ 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/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 6b61b3c486..bd3dbe3a5c 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { bool, func, number, string, oneOfType } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import { CredentialsAPI } from '@api'; import { Credential } from '@types'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs'; @@ -26,6 +27,7 @@ function CredentialLookup({ credentialTypeId, value, history, + i18n, }) { const [credentials, setCredentials] = useState([]); const [count, setCount] = useState(0); @@ -48,6 +50,8 @@ function CredentialLookup({ })(); }, [credentialTypeId, history.location.search]); + // TODO: replace credential type search with REST-based grabbing of cred types + return ( dispatch({ type: 'SELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 20c2e0cf20..f0c88576ca 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -64,24 +64,21 @@ function InstanceGroupsLookup(props) { value={state.selectedItems} options={instanceGroups} optionCount={count} - columns={[ + searchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: false, - isNumeric: true, + name: i18n._(t`Credential Name`), + key: 'credential__name', }, + ]} + sortColumns={[ { - name: i18n._(t`Created`), - key: 'created', - isSortable: false, - isNumeric: true, + name: i18n._(t`Name`), + key: 'name', }, ]} multiple={state.multiple} diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 0286561a6a..938ab80273 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -68,19 +68,25 @@ function InventoryLookup({ value={state.selectedItems} options={inventories} optionCount={count} - columns={[ - { name: i18n._(t`Name`), key: 'name', isSortable: true }, + searchColumns={[ { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: false, - isNumeric: true, + name: i18n._(t`Name`), + key: 'name', + isDefault: true, }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: false, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + sortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} multiple={state.multiple} diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 1effa9282d..8a1e15faf4 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -122,12 +122,25 @@ function MultiCredentialsLookup(props) { value={state.selectedItems} options={credentials} optionCount={credentialsCount} - columns={[ + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + sortColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, }, ]} multiple={isMultiple} diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 9fd5c4bb88..51d3787ef0 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -70,6 +70,27 @@ function OrganizationLookup({ header={i18n._(t`Organization`)} name="organization" qsConfig={QS_CONFIG} + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + sortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} readOnly={!canDelete} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 983a214661..63f4e1c181 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -70,6 +70,41 @@ function ProjectLookup({ renderOptionsList={({ state, dispatch, canDelete }) => ( + {value.length > 0 && ( ( @@ -67,7 +76,7 @@ function OptionsList({ renderToolbar={props => } showPageSizeOptions={false} /> - + ); } @@ -80,7 +89,8 @@ OptionsList.propTypes = { value: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired, optionCount: number.isRequired, - columns: arrayOf(shape({})), + searchColumns: SearchColumns, + sortColumns: SortColumns, multiple: bool, qsConfig: QSConfig.isRequired, selectItem: func.isRequired, @@ -90,7 +100,8 @@ OptionsList.propTypes = { OptionsList.defaultProps = { multiple: false, renderItemChip: null, - columns: [], + searchColumns: [], + sortColumns: [], }; export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx index 25108d790d..7d4574020e 100644 --- a/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx @@ -3,7 +3,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { getQSConfig } from '@util/qs'; import OptionsList from './OptionsList'; -const qsConfig = getQSConfig('test', {}); +const qsConfig = getQSConfig('test', { order_by: 'foo' }); describe('', () => { it('should display list of options', () => { @@ -17,7 +17,8 @@ describe('', () => { value={[]} options={options} optionCount={3} - columns={[]} + searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} deselectItem={() => {}} @@ -39,7 +40,8 @@ describe('', () => { value={[options[1]]} options={options} optionCount={3} - columns={[]} + searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} deselectItem={() => {}} diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.jsx index 8ae2c2c3f3..ed30b364ea 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,43 @@ class NotificationList extends Component { itemCount={itemCount} pluralizedItemName={i18n._(t`Notifications`)} qsConfig={QS_CONFIG} - toolbarColumns={COLUMNS} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'type', + options: [ + ['email', i18n._(t`Email`)], + ['grafana', i18n._(t`Grafana`)], + ['hipchat', i18n._(t`Hipchat`)], + ['irc', i18n._(t`IRC`)], + ['mattermost', i18n._(t`Mattermost`)], + ['pagerduty', i18n._(t`Pagerduty`)], + ['rocketchat', i18n._(t`Rocket.Chat`)], + ['slack', i18n._(t`Slack`)], + ['twilio', i18n._(t`Twilio`)], + ['webhook', i18n._(t`Webhook`)], + ], + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} renderItem={notification => ( {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..1baefcf6a5 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -162,24 +162,33 @@ class ResourceAccessList extends React.Component { itemCount={itemCount} pluralizedItemName="Roles" qsConfig={QS_CONFIG} - toolbarColumns={[ - { - name: i18n._(t`First Name`), - key: 'first_name', - isSortable: true, - isSearchable: true, - }, + toolbarSearchColumns={[ { name: i18n._(t`Username`), key: 'username', - isSortable: true, - isSearchable: true, + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', }, { name: i18n._(t`Last Name`), key: 'last_name', - isSortable: true, - isSearchable: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index a33e71e070..094ee52e78 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -1,98 +1,64 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { withRouter } from 'react-router-dom'; import { - Button as PFButton, - Dropdown as PFDropdown, + Button, + ButtonVariant, + Dropdown, DropdownPosition, DropdownToggle, DropdownItem, - Form, - FormGroup, - TextInput as PFTextInput, + InputGroup, + Select, + SelectOption, + SelectVariant, + TextInput, } from '@patternfly/react-core'; +import { + DataToolbarGroup, + DataToolbarItem, + DataToolbarFilter, +} from '@patternfly/react-core/dist/umd/experimental'; import { SearchIcon } from '@patternfly/react-icons'; - -import { QSConfig } from '@types'; - +import { parseQueryString } from '@util/qs'; +import { QSConfig, SearchColumns } from '@types'; import styled from 'styled-components'; -const TextInput = styled(PFTextInput)` - min-height: 0px; - height: 30px; - --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); - --pf-c-form-control--BorderLeftColor: var(--pf-global--BorderColor--200); -`; - -const Button = styled(PFButton)` - width: 34px; - padding: 0px; - ::after { - border: var(--pf-c-button--BorderWidth) solid - var(--pf-global--BorderColor--200); - } -`; - -const Dropdown = styled(PFDropdown)` - &&& { - /* Higher specificity required because we are selecting unclassed elements */ - > button { - min-height: 30px; - min-width: 70px; - height: 30px; - padding: 0 10px; - margin: 0px; - - ::before { - border-color: var(--pf-global--BorderColor--200); - border-top-left-radius: 3px; - border-bottom-left-radius: 3px; - } - - > span { - /* text element */ - width: auto; - } - - > svg { - /* caret icon */ - margin: 0px; - padding-top: 3px; - padding-left: 3px; - } - } - } -`; - const NoOptionDropdown = styled.div` align-self: stretch; - border: 1px solid var(--pf-global--BorderColor--200); - border-top-left-radius: 3px; - border-bottom-left-radius: 3px; - padding: 3px 7px; + border: 1px solid var(--pf-global--BorderColor--300); + padding: 5px 15px; white-space: nowrap; -`; - -const InputFormGroup = styled(FormGroup)` - flex: 1; + border-bottom-color: var(--pf-global--BorderColor--200); `; class Search extends React.Component { constructor(props) { super(props); - const { sortedColumnKey } = this.props; + const { columns } = this.props; + this.state = { isSearchDropdownOpen: false, - searchKey: sortedColumnKey, + searchKey: columns.find(col => col.isDefault).key, searchValue: '', + isFilterDropdownOpen: false, }; this.handleSearchInputChange = this.handleSearchInputChange.bind(this); this.handleDropdownToggle = this.handleDropdownToggle.bind(this); this.handleDropdownSelect = this.handleDropdownSelect.bind(this); this.handleSearch = this.handleSearch.bind(this); + this.handleTextKeyDown = this.handleTextKeyDown.bind(this); + this.handleFilterDropdownToggle = this.handleFilterDropdownToggle.bind( + this + ); + this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind( + this + ); + this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this); } handleDropdownToggle(isSearchDropdownOpen) { @@ -115,11 +81,9 @@ class Search extends React.Component { const { onSearch, qsConfig } = this.props; const isNonStringField = - qsConfig.integerFields.filter(field => field === searchKey).length || - qsConfig.dateFields.filter(field => field === searchKey).length; + qsConfig.integerFields.find(field => field === searchKey) || + qsConfig.dateFields.find(field => field === searchKey); - // TODO: this will probably become more sophisticated, where date - // fields and string fields are passed to a formatter const actualSearchKey = isNonStringField ? searchKey : `${searchKey}__icontains`; @@ -133,95 +97,213 @@ class Search extends React.Component { this.setState({ searchValue }); } + handleTextKeyDown(e) { + if (e.key && e.key === 'Enter') { + this.handleSearch(e); + } + } + + handleFilterDropdownToggle(isFilterDropdownOpen) { + this.setState({ isFilterDropdownOpen }); + } + + handleFilterDropdownSelect(key, event, actualValue) { + const { onSearch, onRemove } = this.props; + + if (event.target.checked) { + onSearch(`or__${key}`, actualValue); + } else { + onRemove(`or__${key}`, actualValue); + } + } + + handleFilterBooleanSelect(key, selection) { + const { onReplaceSearch } = this.props; + onReplaceSearch(key, selection); + } + render() { const { up } = DropdownPosition; - const { columns, i18n } = this.props; - const { isSearchDropdownOpen, searchKey, searchValue } = this.state; + const { columns, i18n, onRemove, qsConfig, location } = this.props; + const { + isSearchDropdownOpen, + searchKey, + searchValue, + isFilterDropdownOpen, + } = this.state; const { name: searchColumnName } = columns.find( ({ key }) => key === searchKey ); const searchDropdownItems = columns - .filter(({ key, isSearchable }) => isSearchable && key !== searchKey) + .filter(({ key }) => key !== searchKey) .map(({ key, name }) => ( {name} )); + const filterDefaultParams = (paramsArr, config) => { + const defaultParamsKeys = Object.keys(config.defaultParams || {}); + return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); + }; + + const getChipsByKey = () => { + const queryParams = parseQueryString(qsConfig, location.search); + + const queryParamsByKey = {}; + columns.forEach(({ name, key }) => { + queryParamsByKey[key] = { key, label: name, chips: [] }; + }); + const nonDefaultParams = filterDefaultParams( + Object.keys(queryParams || {}), + qsConfig + ); + + nonDefaultParams.forEach(key => { + const columnKey = key.replace('__icontains', '').replace('or__', ''); + const label = columns.filter( + ({ key: keyToCheck }) => columnKey === keyToCheck + ).length + ? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0] + .name + : columnKey; + + queryParamsByKey[columnKey] = { key, label, chips: [] }; + + if (Array.isArray(queryParams[key])) { + queryParams[key].forEach(val => + queryParamsByKey[columnKey].chips.push(val.toString()) + ); + } else { + queryParamsByKey[columnKey].chips.push(queryParams[key].toString()); + } + }); + + return queryParamsByKey; + }; + + const chipsByKey = getChipsByKey(); + return ( -
-
+ + {searchDropdownItems.length > 0 ? ( - - {i18n._(t`Search key dropdown`)} - + + {searchColumnName} + } - > - - {searchColumnName} - - } - dropdownItems={searchDropdownItems} - /> - + isOpen={isSearchDropdownOpen} + dropdownItems={searchDropdownItems} + style={{ width: '100%' }} + /> ) : ( {searchColumnName} )} - - {i18n._(t`Search value text input`)} - - } - style={{ width: '100%' }} - suppressClassNameWarning + + {columns.map(({ key, name, options, isBoolean }) => ( + { + onRemove(chipsByKey[key].key, val); + }} + categoryName={chipsByKey[key] ? chipsByKey[key].label : key} + key={key} + showToolbarItem={searchKey === key} > - - - -
-
+ {(options && ( + + {/* TODO: update value to being object + { actualValue: optionKey, toString: () => label } + currently a pf bug that makes the checked logic + not work with object-based values */} + + + )) || + (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} + /> + + + )} + + ))} + ); } } Search.propTypes = { qsConfig: QSConfig.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: SearchColumns.isRequired, onSearch: PropTypes.func, - sortedColumnKey: PropTypes.string, + onRemove: PropTypes.func, }; Search.defaultProps = { onSearch: null, - sortedColumnKey: 'name', + onRemove: null, }; -export default withI18n()(Search); +export default withI18n()(withRouter(Search)); diff --git a/awx/ui_next/src/components/Search/Search.test.jsx b/awx/ui_next/src/components/Search/Search.test.jsx index d0ee274107..76a7981bfb 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -1,4 +1,8 @@ import React from 'react'; +import { + DataToolbar, + DataToolbarContent, +} from '@patternfly/react-core/dist/umd/experimental'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Search from './Search'; @@ -19,9 +23,7 @@ describe('', () => { }); test('it triggers the expected callbacks', () => { - const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, - ]; + const columns = [{ name: 'Name', key: 'name', isDefault: true }]; const searchBtn = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; @@ -29,12 +31,15 @@ describe('', () => { const onSearch = jest.fn(); search = mountWithContexts( - + {}} + collapseListedFiltersBreakpoint="md" + > + + + + ); search.find(searchTextInput).instance().value = 'test-321'; @@ -46,17 +51,18 @@ describe('', () => { }); test('handleDropdownToggle properly updates state', async () => { - const columns = [ - { name: 'Name', key: 'name', isSortable: true, isSearchable: true }, - ]; + const columns = [{ name: 'Name', key: 'name', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( - + {}} + collapseListedFiltersBreakpoint="md" + > + + + + ).find('Search'); expect(wrapper.state('isSearchDropdownOpen')).toEqual(false); wrapper.instance().handleDropdownToggle(true); @@ -65,22 +71,20 @@ 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( - + {}} + collapseListedFiltersBreakpoint="md" + > + + + + ).find('Search'); expect(wrapper.state('searchKey')).toEqual('name'); wrapper diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index cc53ebdcce..79e4eda02f 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,75 +1,65 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Button, - Dropdown as PFDropdown, + ButtonVariant, + Dropdown, DropdownPosition, DropdownToggle, DropdownItem, - Tooltip, + InputGroup, } from '@patternfly/react-core'; import { SortAlphaDownIcon, - SortAlphaUpIcon, + SortAlphaDownAltIcon, SortNumericDownIcon, - SortNumericUpIcon, + SortNumericDownAltIcon, } from '@patternfly/react-icons'; +import { parseQueryString } from '@util/qs'; +import { SortColumns, QSConfig } from '@types'; import styled from 'styled-components'; -const Dropdown = styled(PFDropdown)` - &&& { - > button { - min-height: 30px; - min-width: 70px; - height: 30px; - padding: 0 10px; - margin: 0px; - - > span { - /* text element within dropdown */ - width: auto; - } - - > svg { - /* caret icon */ - margin: 0px; - padding-top: 3px; - padding-left: 3px; - } - } - } -`; - -const IconWrapper = styled.span` - > svg { - font-size: 18px; - } -`; - -const SortButton = styled(Button)` - padding: 5px 8px; - margin-top: 3px; - - &:hover { - background-color: #0166cc; - color: white; - } -`; - -const SortBy = styled.span` - margin-right: 15px; - font-size: var(--pf-global--FontSize--md); +const NoOptionDropdown = styled.div` + align-self: stretch; + border: 1px solid var(--pf-global--BorderColor--300); + padding: 5px 15px; + white-space: nowrap; + border-bottom-color: var(--pf-global--BorderColor--200); `; class Sort extends React.Component { constructor(props) { super(props); + let sortKey; + let sortOrder; + let isNumeric; + + const { qsConfig, location } = this.props; + const queryParams = parseQueryString(qsConfig, location.search); + if (queryParams.order_by && queryParams.order_by.startsWith('-')) { + sortKey = queryParams.order_by.substr(1); + sortOrder = 'descending'; + } else if (queryParams.order_by) { + sortKey = queryParams.order_by; + sortOrder = 'ascending'; + } + + if (qsConfig.integerFields.find(field => field === sortKey)) { + isNumeric = true; + } else { + isNumeric = false; + } + this.state = { isSortDropdownOpen: false, + sortKey, + sortOrder, + isNumeric, }; this.handleDropdownToggle = this.handleDropdownToggle.bind(this); @@ -82,34 +72,42 @@ class Sort extends React.Component { } handleDropdownSelect({ target }) { - const { columns, onSort, sortOrder } = this.props; + const { columns, onSort, qsConfig } = this.props; + const { sortOrder } = this.state; const { innerText } = target; - const [{ key: searchKey }] = columns.filter( - ({ name }) => name === innerText - ); + const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); - this.setState({ isSortDropdownOpen: false }); - onSort(searchKey, sortOrder); + let isNumeric; + + if (qsConfig.integerFields.find(field => field === sortKey)) { + isNumeric = true; + } else { + isNumeric = false; + } + + this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); + onSort(sortKey, sortOrder); } handleSort() { - const { onSort, sortedColumnKey, sortOrder } = this.props; + const { onSort } = this.props; + const { sortKey, sortOrder } = this.state; const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending'; - - onSort(sortedColumnKey, newSortOrder); + this.setState({ sortOrder: newSortOrder }); + onSort(sortKey, newSortOrder); } render() { const { up } = DropdownPosition; - const { columns, sortedColumnKey, sortOrder, i18n } = this.props; - const { isSortDropdownOpen } = this.state; - const [{ name: sortedColumnName, isNumeric }] = columns.filter( - ({ key }) => key === sortedColumnKey + const { columns, i18n } = this.props; + const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; + const [{ name: sortedColumnName }] = columns.filter( + ({ key }) => key === sortKey ); const sortDropdownItems = columns - .filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey) + .filter(({ key }) => key !== sortKey) .map(({ key, name }) => ( {name} @@ -119,65 +117,57 @@ class Sort extends React.Component { let SortIcon; if (isNumeric) { SortIcon = - sortOrder === 'ascending' ? SortNumericUpIcon : SortNumericDownIcon; + sortOrder === 'ascending' + ? SortNumericDownIcon + : SortNumericDownAltIcon; } else { SortIcon = - sortOrder === 'ascending' ? SortAlphaUpIcon : SortAlphaDownIcon; + sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; } return ( - - {sortDropdownItems.length > 0 && ( - - {i18n._(t`Sort By`)} - - {sortedColumnName} - - } - dropdownItems={sortDropdownItems} - /> - + + {sortedColumnName && ( + + {(sortDropdownItems.length > 0 && ( + + {sortedColumnName} + + } + dropdownItems={sortDropdownItems} + /> + )) || {sortedColumnName}} + + )} - {i18n._(t`Reverse Sort Order`)}} - position="top" - > - - - - - - - +
); } } Sort.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, + qsConfig: QSConfig.isRequired, + columns: SortColumns.isRequired, onSort: PropTypes.func, - sortOrder: PropTypes.string, - sortedColumnKey: PropTypes.string, }; Sort.defaultProps = { onSort: null, - sortOrder: 'ascending', - sortedColumnKey: 'name', }; -export default withI18n()(Sort); +export default withI18n()(withRouter(Sort)); diff --git a/awx/ui_next/src/components/Sort/Sort.test.jsx b/awx/ui_next/src/components/Sort/Sort.test.jsx index 0e0208cd16..1764cf0298 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"]'; @@ -21,12 +30,7 @@ describe('', () => { const onSort = jest.fn(); const wrapper = mountWithContexts( - + ).find('Sort'); wrapper.find(sortBtn).simulate('click'); @@ -36,22 +40,31 @@ 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'); const sortDropdownToggle = wrapper.find('Button'); expect(sortDropdownToggle.length).toBe(1); @@ -60,22 +73,31 @@ 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'); const sortDropdownToggle = wrapper.find('Button'); expect(sortDropdownToggle.length).toBe(1); @@ -84,22 +106,31 @@ 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'); wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); @@ -107,22 +138,31 @@ 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'); expect(wrapper.state('isSortDropdownOpen')).toEqual(false); wrapper.instance().handleDropdownToggle(true); @@ -130,65 +170,70 @@ describe('', () => { }); test('It displays correct sort icon', () => { - const downNumericIconSelector = 'SortNumericDownIcon'; - const upNumericIconSelector = 'SortNumericUpIcon'; - const downAlphaIconSelector = 'SortAlphaDownIcon'; - const upAlphaIconSelector = 'SortAlphaUpIcon'; + const forwardNumericIconSelector = 'SortNumericDownIcon'; + const reverseNumericIconSelector = 'SortNumericDownAltIcon'; + const forwardAlphaIconSelector = 'SortAlphaDownIcon'; + const reverseAlphaIconSelector = 'SortAlphaDownAltIcon'; - const numericColumns = [ - { name: 'ID', key: 'id', isSortable: true, isNumeric: true }, - ]; - const alphaColumns = [ - { name: 'Name', key: 'name', isSortable: true, isNumeric: false }, - ]; + const qsConfigNumDown = { + namespace: 'item', + defaultParams: { page: 1, page_size: 5, order_by: '-id' }, + integerFields: ['page', 'page_size', 'id'], + }; + const qsConfigNumUp = { + namespace: 'item', + defaultParams: { page: 1, page_size: 5, order_by: 'id' }, + integerFields: ['page', 'page_size', 'id'], + }; + const qsConfigAlphaDown = { + namespace: 'item', + defaultParams: { page: 1, page_size: 5, order_by: '-name' }, + integerFields: ['page', 'page_size'], + }; + const qsConfigAlphaUp = { + namespace: 'item', + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size'], + }; + + const numericColumns = [{ name: 'ID', key: 'id' }]; + const alphaColumns = [{ name: 'Name', key: 'name' }]; const onSort = jest.fn(); sort = mountWithContexts( ); - const downNumericIcon = sort.find(downNumericIconSelector); - expect(downNumericIcon.length).toBe(1); + const reverseNumericIcon = sort.find(reverseNumericIconSelector); + expect(reverseNumericIcon.length).toBe(1); sort = mountWithContexts( - + ); - const upNumericIcon = sort.find(upNumericIconSelector); - expect(upNumericIcon.length).toBe(1); + const forwardNumericIcon = sort.find(forwardNumericIconSelector); + expect(forwardNumericIcon.length).toBe(1); sort = mountWithContexts( ); - const downAlphaIcon = sort.find(downAlphaIconSelector); - expect(downAlphaIcon.length).toBe(1); + const reverseAlphaIcon = sort.find(reverseAlphaIconSelector); + expect(reverseAlphaIcon.length).toBe(1); sort = mountWithContexts( - + ); - const upAlphaIcon = sort.find(upAlphaIconSelector); - expect(upAlphaIcon.length).toBe(1); + const forwardAlphaIcon = sort.find(forwardAlphaIconSelector); + expect(forwardAlphaIcon.length).toBe(1); }); }); 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; diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index f907b78be9..4231ef2cc0 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -190,24 +190,25 @@ class HostsList extends Component { pluralizedItemName={i18n._(t`Hosts`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index ecfab0d651..307138de01 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -177,6 +177,32 @@ function InventoryGroupsList({ i18n, location, match }) { itemCount={groupCount} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Is Root Group`), + key: 'parents__isnull', + isBoolean: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} renderItem={item => ( ', () => { }); wrapper.update(); await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + wrapper + .find('DataToolbar Button[aria-label="Delete"]') + .invoke('onClick')(); }); await waitForElement( wrapper, @@ -193,7 +195,9 @@ describe('', () => { }); wrapper.update(); await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + wrapper + .find('DataToolbar Button[aria-label="Delete"]') + .invoke('onClick')(); }); await waitForElement( wrapper, diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index b41d2a808c..9e96793e3f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -132,24 +132,25 @@ function InventoryHosts({ i18n, location, match }) { pluralizedItemName={i18n._(t`Hosts`)} qsConfig={QS_CONFIG} onRowClick={handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index 5b43ce0e04..b0d99846df 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -174,24 +174,25 @@ class InventoriesList extends Component { pluralizedItemName={i18n._(t`Inventories`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.jsx index ad69ab8c18..9ffc05a498 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.jsx @@ -23,12 +23,16 @@ import { getQSConfig, parseQueryString } from '@util/qs'; import JobListItem from './JobListItem'; -const QS_CONFIG = getQSConfig('job', { - page: 1, - page_size: 20, - order_by: '-finished', - not__launch_type: 'sync', -}); +const QS_CONFIG = getQSConfig( + 'job', + { + page: 1, + page_size: 20, + order_by: '-finished', + not__launch_type: 'sync', + }, + ['page', 'page_size', 'id'] +); class JobList extends Component { constructor(props) { @@ -163,18 +167,67 @@ class JobList extends Component { pluralizedItemName="Jobs" qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, + }, + { + name: i18n._(t`ID`), + key: 'id', + }, + { + name: i18n._(t`Label Name`), + key: 'labels__name', + }, + { + name: i18n._(t`Job Type`), + key: `type`, + options: [ + [`project_update`, i18n._(t`SCM Update`)], + [`inventory_update`, i18n._(t`Inventory Sync`)], + [`job`, i18n._(t`Playbook Run`)], + [`ad_hoc_command`, i18n._(t`Command`)], + [`system_job`, i18n._(t`Management Job`)], + [`workflow_job`, i18n._(t`Workflow Job`)], + ], + }, + { + name: i18n._(t`Launched By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Status`), + key: 'status', + options: [ + [`new`, i18n._(t`New`)], + [`pending`, i18n._(t`Pending`)], + [`waiting`, i18n._(t`Waiting`)], + [`running`, i18n._(t`Running`)], + [`successful`, i18n._(t`Successful`)], + [`failed`, i18n._(t`Failed`)], + [`error`, i18n._(t`Error`)], + [`canceled`, i18n._(t`Canceled`)], + ], + }, + { + name: i18n._(t`Limit`), + key: 'job__limit', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`ID`), + key: 'id', }, { name: i18n._(t`Finished`), key: 'finished', - isSortable: true, - isNumeric: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index cba609f3aa..827b7dd092 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -119,24 +119,25 @@ function OrganizationsList({ i18n }) { pluralizedItemName="Organizations" qsConfig={QS_CONFIG} onRowClick={handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx index 832eba41c4..a8a7873d6b 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useLocation } from 'react-router-dom'; - +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import { OrganizationsAPI } from '@api'; import PaginatedDataList from '@components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '@util/qs'; @@ -12,7 +13,7 @@ const QS_CONFIG = getQSConfig('team', { order_by: 'name', }); -function OrganizationTeams({ id }) { +function OrganizationTeams({ id, i18n }) { const location = useLocation(); const [contentError, setContentError] = useState(null); const [hasContentLoading, setHasContentLoading] = useState(false); @@ -46,6 +47,27 @@ function OrganizationTeams({ id }) { itemCount={itemCount} pluralizedItemName="Teams" qsConfig={QS_CONFIG} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created by (username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified by (username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} /> ); } @@ -55,4 +77,4 @@ OrganizationTeams.propTypes = { }; export { OrganizationTeams as _OrganizationTeams }; -export default OrganizationTeams; +export default withI18n()(OrganizationTeams); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index fb6ed4357c..95d12e828a 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -156,24 +156,40 @@ class ProjectsList extends Component { pluralizedItemName={i18n._(t`Projects`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Type`), + key: 'type', + options: [ + [``, i18n._(t`Manual`)], + [`git`, i18n._(t`Git`)], + [`hg`, i18n._(t`Mercurial`)], + [`svn`, i18n._(t`Subversion`)], + [`insights`, i18n._(t`Red Hat Insights`)], + ], }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`SCM URL`), + key: 'scm_url', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx index 7d9ff71e26..38afbc0213 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -154,24 +154,29 @@ class TeamsList extends Component { pluralizedItemName={i18n._(t`Teams`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Organization Name`), + key: 'organization__name', }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 4516d44497..8c99916999 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -214,24 +214,41 @@ class TemplatesList extends Component { pluralizedItemName={i18n._(t`Templates`)} qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ { name: i18n._(t`Name`), key: 'name', - isSortable: true, - isSearchable: true, + isDefault: true, }, { - name: i18n._(t`Modified`), - key: 'modified', - isSortable: true, - isNumeric: true, + name: i18n._(t`Type`), + key: 'type', + options: [ + [`job_template`, i18n._(t`Job Template`)], + [`workflow_job_template`, i18n._(t`Workflow Template`)], + ], }, { - name: i18n._(t`Created`), - key: 'created', - isSortable: true, - isNumeric: true, + name: i18n._(t`Playbook name`), + key: 'job_template__playbook', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Type`), + key: 'type', }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx index 121d9ea6c3..1cfd3b4a69 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx @@ -154,24 +154,33 @@ class UsersList extends Component { pluralizedItemName="Users" qsConfig={QS_CONFIG} onRowClick={this.handleSelect} - toolbarColumns={[ + toolbarSearchColumns={[ + { + name: i18n._(t`Username`), + key: 'username', + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]} + toolbarSortColumns={[ { name: i18n._(t`Username`), key: 'username', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`First Name`), key: 'first_name', - isSortable: true, - isSearchable: true, }, { name: i18n._(t`Last Name`), key: 'last_name', - isSortable: true, - isSearchable: true, }, ]} renderToolbar={props => ( diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 0b6542f3ac..0f263524cf 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -1,5 +1,6 @@ import { shape, + exact, arrayOf, number, string, @@ -249,3 +250,20 @@ export const Group = shape({ inventory: number, variables: string, }); + +export const SearchColumns = arrayOf( + exact({ + name: string.isRequired, + key: string.isRequired, + isDefault: bool, + isBoolean: bool, + options: arrayOf(arrayOf(string, string)), + }) +); + +export const SortColumns = arrayOf( + exact({ + name: string.isRequired, + key: string.isRequired, + }) +); diff --git a/awx/ui_next/src/util/qs.js b/awx/ui_next/src/util/qs.js index e7b38bad36..c31e0061e9 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 (!defaultParams.order_by) { + 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..ca52e53cc1 100644 --- a/awx/ui_next/src/util/qs.test.js +++ b/awx/ui_next/src/util/qs.test.js @@ -121,6 +121,20 @@ describe('qs (qs.js)', () => { }); }); + test('should set order_by in defaultParams if it is not passed', () => { + expect( + getQSConfig('organization', { + page: 1, + page_size: 5, + }) + ).toEqual({ + namespace: 'organization', + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size'], + dateFields: ['modified', 'created'], + }); + }); + test('should throw if no namespace given', () => { expect(() => getQSConfig()).toThrow(); }); @@ -132,7 +146,7 @@ describe('qs (qs.js)', () => { }; expect(getQSConfig('inventory', defaults)).toEqual({ namespace: 'inventory', - defaultParams: { page: 1, page_size: 15 }, + defaultParams: { page: 1, page_size: 15, order_by: 'name' }, integerFields: ['page', 'page_size'], dateFields: ['modified', 'created'], });