ICU-16782 sort admin aliases (#2843)

* feat: 🎸 add sorting to aliases/index route

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16782

* refactor: 💡 use hds table components directly for aliases table

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16782

* feat: 🎸 wire up sorting with alias table, incl. query params

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16782

* test: 💍 add tests for sorting aliases

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16782

* fix: 🐛 use {{true}} instead of 'true' for @isVisuallyHidden

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16782
This commit is contained in:
hashicc authored and GitHub committed 2025-06-03 13:55:11 -04:00
1 parent 5a302b0bcc
commit 204bd10e04
4 files changed
+243 -102

No files matched your search

@@ -20,11 +20,19 @@ export default class ScopesScopeAliasesIndexController extends Controller {
// =attributes
queryParams = ['search', 'page', 'pageSize'];
queryParams = [
'search',
'page',
'pageSize',
'sortAttribute',
'sortDirection',
];
@tracked search;
@tracked page = 1;
@tracked pageSize = 10;
@tracked sortAttribute;
@tracked sortDirection;
/**
* If can list (at least): return default welcome message.
@@ -124,4 +132,11 @@ export default class ScopesScopeAliasesIndexController extends Controller {
this.search = value;
this.page = 1;
}
@action
sortBy(attribute, direction) {
this.sortAttribute = attribute;
this.sortDirection = direction;
this.page = 1;
}
}
@@ -27,6 +27,14 @@ export default class ScopesScopeAliasesIndexRoute extends Route {
pageSize: {
refreshModel: true,
},
sortAttribute: {
refreshModel: true,
replace: true,
},
sortDirection: {
refreshModel: true,
replace: true,
},
};
// =methods
@@ -42,7 +50,14 @@ export default class ScopesScopeAliasesIndexRoute extends Route {
}
retrieveData = restartableTask(
async ({ search, page, pageSize, useDebounce }) => {
async ({
search,
page,
pageSize,
useDebounce,
sortAttribute,
sortDirection,
}) => {
if (useDebounce) {
await timeout(250);
}
@@ -59,12 +74,18 @@ export default class ScopesScopeAliasesIndexRoute extends Route {
collection: 'aliases',
})
) {
const sort = {
attribute: sortAttribute,
direction: sortDirection,
};
aliases = await this.store.query('alias', {
scope_id,
query: { search },
query: { search, sort },
page,
pageSize,
});
totalItems = aliases.meta?.totalItems;
// since we don't receive target info from aliases list API,
// we query the store to fetch target information based on the destination id
@@ -44,113 +44,136 @@
{{#if @model.aliases}}
<Hds::Table
@model={{@model.aliases}}
@columns={{array
(hash label=(t 'resources.alias.title'))
(hash label=(t 'resources.alias.titles.points_to'))
(hash label=(t 'resources.scope.title'))
(hash label=(t 'resources.alias.titles.resource_id'))
(hash label=(t 'titles.actions') isVisuallyHidden=true)
}}
@valign='middle'
@sortBy={{this.sortAttribute}}
@sortOrder={{this.sortDirection}}
@onSort={{this.sortBy}}
>
<:body as |B|>
<B.Tr data-test-alias-row={{B.data.alias.id}}>
<B.Td>
{{#if (can 'read model' B.data.alias)}}
<LinkTo
@route='scopes.scope.aliases.alias'
@model={{B.data.alias.id}}
>
{{B.data.alias.value}}
</LinkTo>
{{else}}
<Hds::Text::Body @tag='p'>
{{B.data.alias.value}}
</Hds::Text::Body>
{{/if}}
{{#if B.data.alias.description}}
<Hds::Text::Body @tag='p'>
{{B.data.alias.description}}
</Hds::Text::Body>
{{/if}}
</B.Td>
<B.Td>
{{#if B.data.target.displayName}}
<div class='aliases-list-cell-item'>
<span><Hds::Icon @name='target' @isInline={{true}} /></span>
<span> {{B.data.target.displayName}} </span>
</div>
{{else}}
<Hds::Text::Body
@tag='p'
@size='100'
@weight='medium'
@color='faint'
>
{{t 'titles.not-set'}}
</Hds::Text::Body>
{{/if}}
</B.Td>
<B.Td>
<Hds::Badge @text={{t 'titles.global'}} @icon='globe' />
</B.Td>
<B.Td>
{{#if B.data.alias.destination_id}}
<Hds::Copy::Snippet
@textToCopy={{B.data.alias.destination_id}}
@color='secondary'
/>
{{/if}}
</B.Td>
<B.Td>
{{#let
(hash
canUpdate=(can 'update model' B.data.alias)
canDelete=(can 'delete model' B.data.alias)
)
as |perms|
}}
{{#if (or perms.canUpdate perms.canDelete)}}
<Hds::Dropdown as |dd|>
<:head as |H|>
<H.Tr>
<H.ThSort
@onClickSort={{fn H.setSortBy 'value'}}
@sortOrder={{if (eq 'value' H.sortBy) H.sortOrder}}
>
{{t 'resources.alias.title'}}
</H.ThSort>
<H.Th>
{{t 'resources.alias.titles.points_to'}}
</H.Th>
<H.Th>
{{t 'resources.scope.title'}}
</H.Th>
<H.Th>
{{t 'resources.alias.titles.resource_id'}}
</H.Th>
<H.Th @isVisuallyHidden={{true}}>
{{t 'titles.actions'}}
</H.Th>
</H.Tr>
</:head>
<dd.ToggleIcon
@icon='more-horizontal'
@text={{t 'actions.manage'}}
@hasChevron={{false}}
@size='small'
/>
{{#if perms.canUpdate}}
<dd.Interactive
@route='scopes.scope.aliases.alias'
@model={{B.data.alias.id}}
>
{{t 'actions.edit'}}
</dd.Interactive>
{{#if B.data.alias.destination_id}}
<:body as |B|>
{{#each @model.aliases as |data|}}
<B.Tr data-test-alias-row={{data.alias.id}}>
<B.Td>
{{#if (can 'read model' data.alias)}}
<LinkTo
@route='scopes.scope.aliases.alias'
@model={{data.alias.id}}
>
{{data.alias.value}}
</LinkTo>
{{else}}
<Hds::Text::Body @tag='p'>
{{data.alias.value}}
</Hds::Text::Body>
{{/if}}
{{#if data.alias.description}}
<Hds::Text::Body @tag='p'>
{{data.alias.description}}
</Hds::Text::Body>
{{/if}}
</B.Td>
<B.Td>
{{#if data.target.displayName}}
<div class='aliases-list-cell-item'>
<span><Hds::Icon
@name='target'
@isInline={{true}}
/></span>
<span> {{data.target.displayName}} </span>
</div>
{{else}}
<Hds::Text::Body
@tag='p'
@size='100'
@weight='medium'
@color='faint'
>
{{t 'titles.not-set'}}
</Hds::Text::Body>
{{/if}}
</B.Td>
<B.Td>
<Hds::Badge @text={{t 'titles.global'}} @icon='globe' />
</B.Td>
<B.Td>
{{#if data.alias.destination_id}}
<Hds::Copy::Snippet
@textToCopy={{data.alias.destination_id}}
@color='secondary'
/>
{{/if}}
</B.Td>
<B.Td>
{{#let
(hash
canUpdate=(can 'update model' data.alias)
canDelete=(can 'delete model' data.alias)
)
as |perms|
}}
{{#if (or perms.canUpdate perms.canDelete)}}
<Hds::Dropdown as |dd|>
<dd.ToggleIcon
@icon='more-horizontal'
@text={{t 'actions.manage'}}
@hasChevron={{false}}
@size='small'
/>
{{#if perms.canUpdate}}
<dd.Interactive
@color='action'
{{on 'click' (fn this.clearAlias B.data.alias)}}
@route='scopes.scope.aliases.alias'
@model={{data.alias.id}}
>
{{t 'actions.clear'}}
{{t 'actions.edit'}}
</dd.Interactive>
{{#if data.alias.destination_id}}
<dd.Interactive
@color='action'
{{on 'click' (fn this.clearAlias data.alias)}}
>
{{t 'actions.clear'}}
</dd.Interactive>
{{/if}}
{{/if}}
{{#if perms.canDelete}}
<dd.Interactive
@color='critical'
{{on 'click' (fn this.deleteAlias data.alias)}}
>
{{t 'actions.delete'}}
</dd.Interactive>
{{/if}}
{{/if}}
</Hds::Dropdown>
{{#if perms.canDelete}}
<dd.Interactive
@color='critical'
{{on 'click' (fn this.deleteAlias B.data.alias)}}
>
{{t 'actions.delete'}}
</dd.Interactive>
{{/if}}
</Hds::Dropdown>
{{/if}}
{{/let}}
</B.Td>
</B.Tr>
{{/if}}
{{/let}}
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
<Rose::Pagination
@@ -10,6 +10,7 @@ import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import { authenticateSession } from 'ember-simple-auth/test-support';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import { faker } from '@faker-js/faker';
module('Acceptance | aliases | list', function (hooks) {
setupApplicationTest(hooks);
@@ -234,4 +235,85 @@ module('Acceptance | aliases | list', function (hooks) {
.dom(NO_RESULTS_MSG_SELECTOR)
.includesText(intl.t('titles.no-results-found'));
});
test('aliases are sorted by created_time descending by default', async function (assert) {
this.server.schema.aliases.all().destroy();
const years = ['2026', '2025', '2024', '2023'];
faker.helpers.shuffle(years).map((year) => {
return this.server.create('alias', {
id: `alt_${year}`,
value: `Alias ${year}`,
created_time: `${year}-01-01T00:00:00Z`,
scope: instances.scopes.global,
destination_id: instances.target.id,
});
});
await visit(urls.aliases);
assert.dom(commonSelectors.TABLE_ROWS).exists({ count: years.length });
assert.dom(commonSelectors.TABLE_ROW(1)).containsText('Alias 2026');
assert.dom(commonSelectors.TABLE_ROW(2)).containsText('Alias 2025');
assert.dom(commonSelectors.TABLE_ROW(3)).containsText('Alias 2024');
assert.dom(commonSelectors.TABLE_ROW(4)).containsText('Alias 2023');
});
test.each(
'sorting',
{
'on value': {
attribute: {
key: 'value',
values: ['alpha', 'beta', 'delta', 'gamma', 'epsilon'],
},
expectedAscendingSort: ['alpha', 'beta', 'delta', 'epsilon', 'gamma'],
column: 1,
},
},
async function (assert, input) {
this.server.schema.aliases.all().destroy();
faker.helpers.shuffle(input.attribute.values).forEach((value) => {
this.server.create('alias', {
[input.attribute.key]: value,
scope: instances.scopes.global,
destination_id: instances.target.id,
});
});
await visit(urls.aliases);
// click the sort button again to sort in ascending order
await click(commonSelectors.TABLE_SORT_BTN(input.column));
assert.true(currentURL().includes('sortDirection=asc'));
assert.true(
currentURL().includes(`sortAttribute=${input.attribute.key}`),
);
assert
.dom(commonSelectors.TABLE_SORT_BTN_ARROW_UP(input.column))
.isVisible();
assert
.dom(commonSelectors.TABLE_ROWS)
.isVisible({ count: input.attribute.values.length });
input.expectedAscendingSort.forEach((expected, index) => {
// nth-child index starts at 1
assert.dom(commonSelectors.TABLE_ROW(index + 1)).containsText(expected);
});
// click the sort button again to sort in descending order
await click(commonSelectors.TABLE_SORT_BTN(input.column));
assert.true(currentURL().includes('sortDirection=desc'));
assert.true(
currentURL().includes(`sortAttribute=${input.attribute.key}`),
);
assert
.dom(commonSelectors.TABLE_SORT_BTN_ARROW_DOWN(input.column))
.isVisible();
input.expectedAscendingSort.toReversed().forEach((expected, index) => {
// nth-child index starts at 1
assert.dom(commonSelectors.TABLE_ROW(index + 1)).containsText(expected);
});
},
);
});