Icu 17087 implement sort for sessions (#2845)

* feat: 🎸 implement sort for sessions

* test: 💍 fix bug + add tests

* Add missing copyright headers

* refactor: 💡 remove fix so it can be its own PR

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17087
This commit is contained in:
Lisbet Alvarez authored and GitHub committed 2025-05-27 17:31:55 -07:00
1 parent 96d7c055b0
commit bada405511
5 files changed
+236 -59

No files matched your search

@@ -30,6 +30,8 @@ export default class ScopesScopeSessionsIndexController extends Controller {
{ status: { type: 'array' } },
'page',
'pageSize',
'sortAttribute',
'sortDirection',
];
@tracked search;
@@ -42,6 +44,8 @@ export default class ScopesScopeSessionsIndexController extends Controller {
];
@tracked page = 1;
@tracked pageSize = 10;
@tracked sortAttribute;
@tracked sortDirection;
/**
* Returns object of filters to be used for displaying selected filters
@@ -115,4 +119,16 @@ export default class ScopesScopeSessionsIndexController extends Controller {
async cancelSession(session) {
await session.cancelSession();
}
/**
* 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;
}
}
@@ -39,6 +39,14 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
pageSize: {
refreshModel: true,
},
sortAttribute: {
refreshModel: true,
replace: true,
},
sortDirection: {
refreshModel: true,
replace: true,
},
};
allSessions;
@@ -59,7 +67,17 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
}
retrieveData = restartableTask(
async ({ search, users, targets, status, page, pageSize, useDebounce }) => {
async ({
search,
users,
targets,
status,
page,
pageSize,
sortAttribute,
sortDirection,
useDebounce,
}) => {
if (useDebounce) {
await timeout(250);
}
@@ -83,10 +101,15 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
filters.status.push({ equals: item });
});
const sort = {
attribute: sortAttribute,
direction: sortDirection,
};
const queryOptions = {
scope_id,
include_terminated: true,
query: { search, filters },
query: { search, filters, sort },
page,
pageSize,
};
@@ -82,51 +82,65 @@
{{#if @model.sessions}}
<Hds::Table
@columns={{array
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.user.title'))
(hash label=(t 'resources.target.title'))
(hash label=(t 'form.started.label'))
(hash key='status' label=(t 'form.status.label') isSortable=true)
(hash label=(t 'titles.actions'))
}}
@sortBy='status'
@model={{@model.sessions}}
@valign='middle'
@onSort={{this.onSort}}
@sortBy={{this.sortAttribute}}
@sortOrder={{this.sortDirection}}
>
<:head as |H|>
<H.Tr>
<H.ThSort
@onClickSort={{fn H.setSortBy 'id'}}
@sortOrder={{if (eq 'id' H.sortBy) H.sortOrder}}
>{{t 'form.id.label'}}</H.ThSort>
<H.Th>{{t 'resources.user.title'}}</H.Th>
<H.Th>{{t 'resources.target.title'}}</H.Th>
<H.ThSort
@onClickSort={{fn H.setSortBy 'created_time'}}
@sortOrder={{if (eq 'created_time' H.sortBy) H.sortOrder}}
>{{t 'form.started.label'}}</H.ThSort>
<H.ThSort
@onClickSort={{fn H.setSortBy 'status'}}
@sortOrder={{if (eq 'status' H.sortBy) H.sortOrder}}
>{{t 'form.status.label'}}</H.ThSort>
<H.Th>{{t 'titles.actions'}}</H.Th>
</H.Tr>
</:head>
<:body as |B|>
<B.Tr data-test-sessions-table-row={{B.data.id}}>
<B.Td>
<Hds::Copy::Snippet
data-test-session={{B.data.id}}
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{B.data.user.displayName}}
</B.Td>
<B.Td>
{{B.data.target.displayName}}
</B.Td>
<B.Td>
<time datetime={{format-date-iso B.data.created_time}}>
{{format-date-iso-human B.data.created_time}}
</time>
</B.Td>
<B.Td>
<SessionStatus @model={{B.data}} />
</B.Td>
<B.Td>
{{#if (can 'cancel session' B.data)}}
<Hds::Button
{{#each @model.sessions as |data|}}
<B.Tr data-test-sessions-table-row={{data.id}}>
<B.Td>
<Hds::Copy::Snippet
data-test-session={{data.id}}
@textToCopy={{data.id}}
@color='secondary'
@text={{t 'actions.cancel'}}
{{on 'click' (fn this.cancelSession B.data)}}
/>
{{/if}}
</B.Td>
</B.Tr>
</B.Td>
<B.Td>
{{data.user.displayName}}
</B.Td>
<B.Td>
{{data.target.displayName}}
</B.Td>
<B.Td>
<time datetime={{format-date-iso data.created_time}}>
{{format-date-iso-human data.created_time}}
</time>
</B.Td>
<B.Td>
<SessionStatus @model={{data}} />
</B.Td>
<B.Td>
{{#if (can 'cancel session' data)}}
<Hds::Button
@color='secondary'
@text={{t 'actions.cancel'}}
{{on 'click' (fn this.cancelSession data)}}
/>
{{/if}}
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
<Rose::Pagination
+133 -17
View File
@@ -4,30 +4,45 @@
*/
import { module, test } from 'qunit';
import {
visit,
currentURL,
click,
fillIn,
waitUntil,
findAll,
} from '@ember/test-helpers';
import { visit, currentURL, click, fillIn, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { Response } from 'miragejs';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { faker } from '@faker-js/faker';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
import {
STATUS_SESSION_ACTIVE,
STATUS_SESSION_CANCELING,
STATUS_SESSION_PENDING,
STATUS_SESSION_TERMINATED,
} from 'api/models/session';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import * as selectors from './selectors';
module('Acceptance | sessions | list', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
const NO_RESULTS_MSG_SELECTOR = '[data-test-no-session-results]';
const SESSION_ID_SELECTOR = (id) => `[data-test-session="${id}"]`;
const CREATED_TIME_VALUES_ARRAY = [
'2020-01-01T00:00:01.010Z',
'2020-01-01T00:00:10.100Z',
'2020-01-01T00:10:01.000Z',
'2020-01-01T01:00:10.000Z',
'2020-01-01T10:00:00.000Z',
];
const CREATED_TIME_ISO_VALUES_ARRAY = [
'2020-01-01 00:00:01',
'2020-01-01 00:00:10',
'2020-01-01 00:10:01',
'2020-01-01 01:00:10',
'2020-01-01 10:00:00',
];
const ID_VALUES_ARRAY = ['i_0001', 'i_0010', 'i_0100', 'i_1000', 'i_10000'];
const instances = {
scopes: {
@@ -193,9 +208,9 @@ module('Acceptance | sessions | list', function (hooks) {
await click(commonSelectors.HREF(urls.sessions));
await fillIn(commonSelectors.SEARCH_INPUT, sessionId);
await waitUntil(() => findAll(SESSION_ID_SELECTOR(sessionId)).length === 1);
await waitFor(selectors.TABLE_SESSION_ID(sessionId), { count: 1 });
assert.dom(SESSION_ID_SELECTOR(sessionId)).hasText(sessionId);
assert.dom(selectors.TABLE_SESSION_ID(sessionId)).hasText(sessionId);
});
test('user can search for sessions and get no results', async function (assert) {
@@ -204,9 +219,9 @@ module('Acceptance | sessions | list', function (hooks) {
await click(commonSelectors.HREF(urls.sessions));
await fillIn(commonSelectors.SEARCH_INPUT, sessionId);
await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1);
await waitFor(selectors.NO_RESULTS_MSG, { count: 1 });
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
assert.dom(selectors.NO_RESULTS_MSG).includesText('No results found');
});
test('user can filter for sessions by user', async function (assert) {
@@ -220,7 +235,7 @@ module('Acceptance | sessions | list', function (hooks) {
await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.dev.id));
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('user'));
assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists();
assert.dom(selectors.TABLE_SESSION_ID(instances.sessions[2].id)).exists();
assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 1 });
});
@@ -250,7 +265,7 @@ module('Acceptance | sessions | list', function (hooks) {
await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.sshTarget.id));
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('target'));
assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists();
assert.dom(selectors.TABLE_SESSION_ID(instances.sessions[2].id)).exists();
assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 1 });
});
@@ -273,6 +288,107 @@ module('Acceptance | sessions | list', function (hooks) {
await click(commonSelectors.FILTER_DROPDOWN_ITEM('active'));
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('status'));
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
assert.dom(selectors.NO_RESULTS_MSG).includesText('No results found');
});
test('sessions table is sorted by `created_time` descending by default', async function (assert) {
this.server.schema.sessions.all().destroy();
const expectedDescendingSort = CREATED_TIME_ISO_VALUES_ARRAY.toReversed();
faker.helpers.shuffle(CREATED_TIME_VALUES_ARRAY).forEach((value) => {
this.server.create('session', {
created_time: value,
scope: instances.scopes.project,
status: STATUS_SESSION_ACTIVE,
});
});
await visit(urls.sessions);
assert
.dom(commonSelectors.TABLE_ROWS)
.isVisible({ count: CREATED_TIME_VALUES_ARRAY.length });
expectedDescendingSort.forEach((expected, index) => {
// nth-child index starts at 1
assert.dom(commonSelectors.TABLE_ROW(index + 1)).containsText(expected);
});
});
test.each(
'sorting',
{
'on id': {
attribute: {
key: 'id',
values: ID_VALUES_ARRAY,
},
expectedAscendingSort: ID_VALUES_ARRAY,
column: 1,
},
'on created_time': {
attribute: {
key: 'created_time',
values: CREATED_TIME_VALUES_ARRAY,
},
expectedAscendingSort: CREATED_TIME_ISO_VALUES_ARRAY,
column: 4,
},
'on status': {
attribute: {
key: 'status',
values: [
STATUS_SESSION_ACTIVE,
STATUS_SESSION_CANCELING,
STATUS_SESSION_PENDING,
STATUS_SESSION_TERMINATED,
],
},
expectedAscendingSort: ['Active', 'Canceling', 'Pending', 'Terminated'],
column: 5,
},
},
async function (assert, input) {
this.server.schema.sessions.all().destroy();
faker.helpers.shuffle(input.attribute.values).forEach((value) => {
this.server.create('session', {
[input.attribute.key]: value,
scope: instances.scopes.project,
});
});
await visit(urls.sessions);
await click(selectors.CLEAR_FILTERS_BTN);
// click the sort button to sort in ascending order for provided column key
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);
});
},
);
});
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export const TABLE_SESSION_ID = (id) => `[data-test-session="${id}"]`;
export const NO_RESULTS_MSG = '[data-test-no-session-results]';
export const CLEAR_FILTERS_BTN = '.filters-applied button:last-child';