feat: 🎸 Add sorting to sessions started column (#3174)

* feat: 🎸 Add sorting to sessions started column

* test: 💍 add missing a11y ignores for existing color issue

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18520
This commit is contained in:
Lisbet Alvarez authored and GitHub committed 2026-02-26 09:28:00 -08:00
1 parent df24ba6a15
commit d6e8e58ca1
6 files changed
+190 -80

No files matched your search

@@ -35,6 +35,8 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
{ scopes: { type: 'array' } },
'page',
'pageSize',
'sortAttribute',
'sortDirection',
];
@tracked targets = [];
@@ -46,14 +48,20 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
@tracked scopes = [];
@tracked page = 1;
@tracked pageSize = 10;
@tracked sortAttribute;
@tracked sortDirection;
// =methods
/**
* A list of sessions sorted by created time then sorted by availability
* A list of sessions sorted by created time then sorted by availability it no sort
* query params are set, otherwise sorted by the specified attribute and direction.
* @type {[SessionModel]}
*/
get sortedSessions() {
if (this.sortAttribute && this.sortDirection) {
return this.model.sessions;
}
return orderBy(
this.model.sessions,
['isAvailable', 'created_time'],
@@ -104,6 +112,8 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
};
}
// =actions
/**
* Sets the query params to value of selectedItems
* to trigger a query and closes the dropdown
@@ -116,7 +126,17 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
this.page = 1;
}
// =actions
/**
* Sets sort values and sets page to 1
* @param {string} sortBy
* @param {string} sortOrder
*/
@action
onSort(sortBy, sortOrder) {
this.sortAttribute = sortBy;
this.sortDirection = sortOrder;
this.page = 1;
}
/**
* Cancels the specified session and notifies user of success or error.
@@ -42,6 +42,14 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
pageSize: {
refreshModel: true,
},
sortAttribute: {
refreshModel: true,
replace: true,
},
sortDirection: {
refreshModel: true,
replace: true,
},
};
allSessions;
@@ -67,7 +75,15 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
* all targets and all projects for filtering options for the current user.
* @return {Promise<{sessions: [SessionModel], projects: [ScopeModel], allSessions: [SessionModel], associatedTargets: [TargetModel], totalItems: number}>}
*/
async model({ targets, status, scopes, page, pageSize }) {
async model({
targets,
status,
scopes,
page,
pageSize,
sortAttribute,
sortDirection,
}) {
const projects = this.modelFor('scopes.scope.projects');
const orgScope = this.modelFor('scopes.scope');
// orgFilter used to narrow down resources to only those under
@@ -90,10 +106,11 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
filters.scope_id.push({ equals: scope });
});
const sort = { attribute: sortAttribute, direction: sortDirection };
const queryOptions = {
scope_id: orgScope.id,
recursive: true,
query: { filters },
query: { filters, sort },
page,
pageSize,
force_refresh: true,
@@ -63,90 +63,100 @@
{{/if}}
{{#if @model.sessions}}
<Hds::Table
@onSort={{this.onSort}}
@sortBy={{this.sortAttribute}}
@sortOrder={{this.sortDirection}}
@model={{this.sortedSessions}}
@columns={{array
(hash label=(t 'form.started.label'))
(hash label=(t 'form.session_id.label'))
(hash label=(t 'resources.target.title'))
(hash label=(t 'form.proxy_url.label'))
(hash label=(t 'form.status.label'))
(hash
label=(t 'titles.actions')
isVisuallyHidden=true
align='right'
width='60px'
)
}}
@valign='middle'
>
<:head as |H|>
<H.Tr>
<H.ThSort
@onClickSort={{fn H.setSortBy 'created_time'}}
@sortOrder={{if (eq 'created_time' H.sortBy) H.sortOrder}}
>{{t 'form.started.label'}}</H.ThSort>
<H.Th>{{t 'form.session_id.label'}}</H.Th>
<H.Th>{{t 'resources.target.title'}}</H.Th>
<H.Th>{{t 'form.proxy_url.label'}}</H.Th>
<H.Th>{{t 'form.status.label'}}</H.Th>
<H.Th @isVisuallyHidden={{true}} @align='right' @width='60px'>{{t
'titles.actions'
}}</H.Th>
</H.Tr>
</:head>
<:body as |B|>
<B.Tr>
<B.Td>
<Hds::Time @date={{B.data.created_time}} @display='friendly-local' />
</B.Td>
<B.Td>
<div class='link-copy-button-container'>
{{#if B.data.isAvailable}}
<Hds::Link::Inline
@route='scopes.scope.projects.sessions.session.index'
@model={{B.data.id}}
data-test-session-detail-link={{B.data.id}}
>
{{B.data.id}}
</Hds::Link::Inline>
{{else}}
<Hds::Text::Body data-test-session-id-copy={{B.data.id}}>
{{B.data.id}}
</Hds::Text::Body>
{{/if}}
<Hds::Copy::Button
@text={{B.data.id}}
@isIconOnly={{true}}
@textToCopy={{B.data.id}}
data-test-session-id-copy={{B.data.id}}
{{#each this.sortedSessions as |session|}}
<B.Tr>
<B.Td>
<Hds::Time
@date={{session.created_time}}
@display='friendly-local'
/>
</div>
</B.Td>
<B.Td>
{{B.data.target.displayName}}
</B.Td>
{{!
Two if's here because:
1) Local proxy info is only relevant if the session is
active or pending (aka isAvailable). Once the session
is canceled or in the process thereof, the local proxy
may no longer be used.
2) The session actually has proxy information associated
with it. It's possible for the user to start sessions
from other devices (or even another tab), in which case we
won't have the local proxy info available here.
}}
<B.Td>
{{#if B.data.isAvailable}}
{{#if B.data.proxy}}
<Hds::Copy::Snippet
@textToCopy={{B.data.proxy}}
</B.Td>
<B.Td>
<div class='link-copy-button-container'>
{{#if session.isAvailable}}
<Hds::Link::Inline
@route='scopes.scope.projects.sessions.session.index'
@model={{session.id}}
data-test-session-detail-link={{session.id}}
>
{{session.id}}
</Hds::Link::Inline>
{{else}}
<Hds::Text::Body data-test-session-id-copy={{session.id}}>
{{session.id}}
</Hds::Text::Body>
{{/if}}
<Hds::Copy::Button
@text={{session.id}}
@isIconOnly={{true}}
@textToCopy={{session.id}}
data-test-session-id-copy={{session.id}}
/>
</div>
</B.Td>
<B.Td>
{{session.target.displayName}}
</B.Td>
{{!
Two if's here because:
1) Local proxy info is only relevant if the session is
active or pending (aka isAvailable). Once the session
is canceled or in the process thereof, the local proxy
may no longer be used.
2) The session actually has proxy information associated
with it. It's possible for the user to start sessions
from other devices (or even another tab), in which case we
won't have the local proxy info available here.
}}
<B.Td>
{{#if session.isAvailable}}
{{#if session.proxy}}
<Hds::Copy::Snippet
@textToCopy={{session.proxy}}
@color='secondary'
/>
{{/if}}
{{/if}}
</B.Td>
<B.Td>
<SessionStatus @model={{session}} />
</B.Td>
<B.Td @align='right' width='60px'>
{{#if (can 'cancel session' session)}}
<Hds::Button
@text={{t 'actions.cancel'}}
@icon='x'
@isIconOnly={{true}}
@color='secondary'
{{on 'click' (fn this.cancelSession session)}}
data-test-session-cancel-button={{session.id}}
/>
{{/if}}
{{/if}}
</B.Td>
<B.Td>
<SessionStatus @model={{B.data}} />
</B.Td>
<B.Td @align='right' width='60px'>
{{#if (can 'cancel session' B.data)}}
<Hds::Button
@text={{t 'actions.cancel'}}
@icon='x'
@isIconOnly={{true}}
@color='secondary'
{{on 'click' (fn this.cancelSession B.data)}}
data-test-session-cancel-button={{B.data.id}}
/>
{{/if}}
</B.Td>
</B.Tr>
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
<Rose::Pagination
@@ -28,6 +28,11 @@ module('Acceptance | projects | sessions | index', function (hooks) {
const APP_STATE_TITLE =
'[data-test-no-sessions] .hds-application-state__title';
const TABLE_SORT_BTN = 'thead tr th:nth-child(1) button';
const TABLE_SORT_BTN_ARROW_UP =
'thead tr th:nth-child(1) button .hds-icon-arrow-up';
const TABLE_SORT_BTN_ARROW_DOWN =
'thead tr th:nth-child(1) button .hds-icon-arrow-down';
const instances = {
scopes: {
@@ -577,4 +582,41 @@ module('Acceptance | projects | sessions | index', function (hooks) {
assert.strictEqual(currentURL(), urls.globalSessions);
assert.dom('tbody tr').exists({ count: sessionsCount });
});
test('sorting by session created time updates url', async function (assert) {
setRunOptions({
rules: {
'color-contrast': {
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2026-02-24
enabled: false,
},
},
});
this.stubCacheDaemonSearch(
'sessions',
'sessions',
'targets',
'sessions',
'targets',
'sessions',
'targets',
);
await visit(urls.projects);
await click(`[href="${urls.sessions}"]`);
await click(TABLE_SORT_BTN);
assert.true(currentURL().includes('sortAttribute=created_time'));
assert.true(currentURL().includes('sortDirection=asc'));
assert.dom(TABLE_SORT_BTN_ARROW_UP).isVisible();
await click(TABLE_SORT_BTN);
assert.true(currentURL().includes('sortAttribute=created_time'));
assert.true(currentURL().includes('sortDirection=desc'));
assert.dom(TABLE_SORT_BTN_ARROW_DOWN).isVisible();
});
});
@@ -884,6 +884,15 @@ module('Acceptance | projects | targets | index', function (hooks) {
});
test('sorting by target name updates url', async function (assert) {
setRunOptions({
rules: {
'color-contrast': {
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2026-02-24
enabled: false,
},
},
});
this.stubCacheDaemonSearch(
'sessions',
'targets',
@@ -148,5 +148,17 @@ module(
assert.strictEqual(session.status, STATUS_SESSION_CANCELING);
});
test('onSort action sets expected values correctly', async function (assert) {
this.stubCacheDaemonSearch();
assert.notOk(controller.sortAttribute);
assert.notOk(controller.sortDirection);
controller.onSort('created_time', 'desc');
assert.strictEqual(controller.page, 1);
assert.strictEqual(controller.sortAttribute, 'created_time');
assert.strictEqual(controller.sortDirection, 'desc');
});
},
);