Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion awx/ui/src/components/JobList/JobList.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ function JobList({
not__launch_type: 'sync',
...defaultParams,
},
['id', 'page', 'page_size']
['id', 'page', 'page_size'],
['created', 'modified', 'finished']
);

const { me } = useConfig();
Expand Down Expand Up @@ -248,6 +249,14 @@ function JobList({
name: t`Limit`,
key: 'job__limit',
},
{
name: t`Created`,
key: 'created',
},
{
name: t`Finished`,
key: 'finished',
},
]}
headerRow={
<HeaderRow qsConfig={qsConfig} isExpandable>
Expand Down
93 changes: 91 additions & 2 deletions awx/ui/src/components/Search/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ const SubmitButtonWrapper = styled.div`
`;
SubmitButtonWrapper.displayName = 'SubmitButtonWrapper';

const DateInputGroup = styled(InputGroup)`
/* keep the operator select at its natural width so the date input
next to it stays visible */
& > .pf-c-select {
width: auto;
flex: 0 0 auto;
}
& > .pf-c-form-control {
flex: 1 1 auto;
}
`;

const NoOptionDropdown = styled.div`
align-self: stretch;
border: 1px solid var(--pf-global--BorderColor--300);
Expand Down Expand Up @@ -69,6 +81,8 @@ function Search({
);
const [searchValue, setSearchValue] = useState('');
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
const [dateOperator, setDateOperator] = useState('gte');
const [isDateOperatorOpen, setIsDateOperatorOpen] = useState(false);

const params = parseQueryString(qsConfig, location.search);
if (params?.host_filter) {
Expand Down Expand Up @@ -96,7 +110,13 @@ function Search({
);
onShowAdvancedSearch(actualSearchKey === 'advanced');
setIsFilterDropdownOpen(false);
setIsDateOperatorOpen(false);
setSearchKey(actualSearchKey);
// a value typed for the previous key must not leak into the next one -
// a controlled date input renders a stale text value as an empty field
// while leaving the submit button enabled, allowing a non-date value
// through to the API
setSearchValue('');
};

const handleSearch = (e) => {
Expand All @@ -115,6 +135,26 @@ function Search({
}
};

const dateOperators = [
['gte', t`On or after`],
['lt', t`Before`],
];

const handleDateSearch = (e) => {
e.preventDefault();

if (searchValue) {
onSearch(`${searchKey}__${dateOperator}`, searchValue);
setSearchValue('');
}
};

const handleDateKeyDown = (e) => {
if (e.key && e.key === 'Enter') {
handleDateSearch(e);
}
};

const handleFilterDropdownSelect = (key, event, actualValue) => {
if (event.target.checked) {
onSearch(key, actualValue);
Expand Down Expand Up @@ -237,10 +277,59 @@ function Search({
{booleanLabels.false || t`No`}
</SelectOption>
</Select>
)) ||
((qsConfig.dateFields || []).includes(key) && (
<DateInputGroup>
<Select
variant={SelectVariant.single}
className="dateOperatorSelect"
aria-label={t`Date operator select`}
typeAheadAriaLabel={t`Date operator select`}
onToggle={setIsDateOperatorOpen}
onSelect={(event, selection) => {
const [op] = dateOperators.find(
([, label]) => label === selection
);
setDateOperator(op);
setIsDateOperatorOpen(false);
}}
selections={
dateOperators.find(([op]) => op === dateOperator)[1]
}
isOpen={isDateOperatorOpen}
ouiaId={`date-operator-select-${key}`}
isDisabled={isDisabled}
noResultsFoundText={t`No results found`}
>
{dateOperators.map(([op, label]) => (
<SelectOption key={op} value={label}>
{label}
</SelectOption>
))}
</Select>
<TextInput
data-cy="date-search-input"
type="date"
aria-label={t`Date search input`}
value={searchValue}
onChange={setSearchValue}
onKeyDown={handleDateKeyDown}
isDisabled={isDisabled}
/>
<SubmitButtonWrapper $disabled={!searchValue}>
<Button
ouiaId="date-search-submit-button"
variant={ButtonVariant.control}
isDisabled={!searchValue || isDisabled}
aria-label={t`Search submit button`}
onClick={handleDateSearch}
>
<SearchIcon />
</Button>
</SubmitButtonWrapper>
</DateInputGroup>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
data-cy="search-text-input"
type={
Expand Down
157 changes: 157 additions & 0 deletions awx/ui/src/components/Search/Search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,4 +365,161 @@ describe('<Search />', () => {
const fooFilterWrapper = wrapper.find('ToolbarFilter[categoryName="foo"]');
expect(fooFilterWrapper.prop('chips')[0].key).toEqual('foo:bar');
});

describe('date fields', () => {
const dateColumns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Created', key: 'created' },
];
const dateInput = 'input[aria-label="Date search input"]';
const dateSubmitBtn = 'button[aria-label="Search submit button"]';

function mountSearch(onSearch) {
return mountWithContexts(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search
qsConfig={QS_CONFIG}
columns={dateColumns}
onSearch={onSearch}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent>
</Toolbar>
);
}

test('renders date input and operator select for a date column', () => {
search = mountSearch(jest.fn());
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
expect(search.find(dateInput)).toHaveLength(1);
expect(search.find(dateInput).prop('type')).toBe('date');
expect(
search.find('Select[aria-label="Date operator select"]')
).toHaveLength(1);
});

test('searching submits the column key with the default operator', () => {
const onSearch = jest.fn();
search = mountSearch(onSearch);
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
search.find(dateInput).instance().value = '2026-06-01';
search.find(dateInput).simulate('change');
search.find(dateSubmitBtn).simulate('click');
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toHaveBeenCalledWith('created__gte', '2026-06-01');
});

test('switching the operator changes the submitted parameter', () => {
const onSearch = jest.fn();
search = mountSearch(onSearch);
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
act(() => {
search
.find('Select[aria-label="Date operator select"]')
.prop('onSelect')(null, 'Before');
});
search.update();
search.find(dateInput).instance().value = '2026-06-30';
search.find(dateInput).simulate('change');
search.find(dateSubmitBtn).simulate('click');
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toHaveBeenCalledWith('created__lt', '2026-06-30');
});

test('a value typed for a text column does not leak into a date search', () => {
const onSearch = jest.fn();
search = mountSearch(onSearch);
const textInput = 'input[aria-label="Search text input"]';
search.find(textInput).instance().value = 'foo';
search.find(textInput).simulate('change');
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
expect(search.find(dateInput).prop('value')).toBe('');
expect(
search.find('button[aria-label="Search submit button"]').prop(
'disabled'
)
).toBe(true);
});

test('Enter in the date input submits the search', () => {
const onSearch = jest.fn();
search = mountSearch(onSearch);
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
search.find(dateInput).instance().value = '2026-06-15';
search.find(dateInput).simulate('change');
search.find(dateInput).simulate('keydown', { key: 'Enter' });
expect(onSearch).toHaveBeenCalledWith('created__gte', '2026-06-15');
});

test('operator dropdown does not stay open across column switches', () => {
search = mountSearch(jest.fn());
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
act(() => {
search
.find('Select[aria-label="Date operator select"]')
.prop('onToggle')(true);
});
search.update();
expect(
search.find('Select[aria-label="Date operator select"]').prop('isOpen')
).toBe(true);
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Name' } }
);
});
act(() => {
search.find('Select[aria-label="Simple key select"]').prop('onSelect')(
{ target: { innerText: 'Created' } }
);
});
search.update();
expect(
search.find('Select[aria-label="Date operator select"]').prop('isOpen')
).toBe(false);
});

test('non-date columns keep the plain text input', () => {
search = mountSearch(jest.fn());
expect(search.find(dateInput)).toHaveLength(0);
expect(
search.find('input[aria-label="Search text input"]')
).toHaveLength(1);
});
});
});
23 changes: 16 additions & 7 deletions awx/ui/src/components/Search/getChipsByKey.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,22 @@ export default function getChipsByKey(queryParams, columns, qsConfig) {

nonDefaultParams.forEach((key) => {
const columnKey = key;
const label = columns.filter(
({ key: keyToCheck }) => columnKey === keyToCheck
).length
? `${
columns.find(({ key: keyToCheck }) => columnKey === keyToCheck).name
} (${key})`
: columnKey;
let label = columnKey;
if (columns.some(({ key: keyToCheck }) => columnKey === keyToCheck)) {
label = `${
columns.find(({ key: keyToCheck }) => columnKey === keyToCheck).name
} (${key})`;
} else {
// date filters are submitted as <column>__gte / <column>__lt etc.;
// label them with the base column's name
const baseKey = columnKey.replace(/__(gte?|lte?)$/, '');
const baseColumn = columns.find(
({ key: keyToCheck }) => baseKey === keyToCheck
);
if (baseColumn) {
label = `${baseColumn.name} (${key})`;
}
}

queryParamsByKey[columnKey] = { key, label, chips: [] };

Expand Down
20 changes: 20 additions & 0 deletions awx/ui/src/components/Search/getChipsByKey.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,24 @@ describe('getChipsByKey', () => {
},
});
});

test('should label date-operator params with the base column name', () => {
const columns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Created', key: 'created' },
];
const queryParams = { created__gte: '2026-06-01', created__lt: '2026-06-30' };
const config = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size'],
dateFields: ['modified', 'created'],
};
const chips = getChipsByKey(queryParams, columns, config);
expect(chips.created__gte.label).toBe('Created (created__gte)');
expect(chips.created__lt.label).toBe('Created (created__lt)');
expect(chips.created__gte.chips).toEqual([
{ key: 'created__gte:2026-06-01', node: '2026-06-01' },
]);
});
});
7 changes: 6 additions & 1 deletion awx/ui/src/screens/ActivityStream/ActivityStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ function ActivityStream() {
page_size: 20,
order_by: '-timestamp',
},
['id', 'page', 'page_size']
['id', 'page', 'page_size'],
['timestamp']
);

const {
Expand Down Expand Up @@ -246,6 +247,10 @@ function ActivityStream() {
name: t`Initiated by (username)`,
key: 'actor__username__icontains',
},
{
name: t`Time`,
key: 'timestamp',
},
]}
toolbarSortColumns={[
{
Expand Down
Loading