From bada405511af2057c1188213f3a1ff7fb8b1285a Mon Sep 17 00:00:00 2001
From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
Date: Tue, 27 May 2025 17:31:55 -0700
Subject: [PATCH] Icu 17087 implement sort for sessions (#2845)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
---
.../scopes/scope/sessions/index.js | 16 ++
.../app/routes/scopes/scope/sessions/index.js | 27 +++-
.../templates/scopes/scope/sessions/index.hbs | 94 ++++++-----
.../tests/acceptance/sessions/list-test.js | 150 ++++++++++++++++--
.../tests/acceptance/sessions/selectors.js | 8 +
5 files changed, 236 insertions(+), 59 deletions(-)
create mode 100644 ui/admin/tests/acceptance/sessions/selectors.js
diff --git a/ui/admin/app/controllers/scopes/scope/sessions/index.js b/ui/admin/app/controllers/scopes/scope/sessions/index.js
index 21030eeae..26791e78c 100644
--- a/ui/admin/app/controllers/scopes/scope/sessions/index.js
+++ b/ui/admin/app/controllers/scopes/scope/sessions/index.js
@@ -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;
+ }
}
diff --git a/ui/admin/app/routes/scopes/scope/sessions/index.js b/ui/admin/app/routes/scopes/scope/sessions/index.js
index d166bea1f..e90430659 100644
--- a/ui/admin/app/routes/scopes/scope/sessions/index.js
+++ b/ui/admin/app/routes/scopes/scope/sessions/index.js
@@ -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,
};
diff --git a/ui/admin/app/templates/scopes/scope/sessions/index.hbs b/ui/admin/app/templates/scopes/scope/sessions/index.hbs
index 5b1d51bfa..370a1514a 100644
--- a/ui/admin/app/templates/scopes/scope/sessions/index.hbs
+++ b/ui/admin/app/templates/scopes/scope/sessions/index.hbs
@@ -82,51 +82,65 @@
{{#if @model.sessions}}
+ <:head as |H|>
+
+ {{t 'form.id.label'}}
+ {{t 'resources.user.title'}}
+ {{t 'resources.target.title'}}
+ {{t 'form.started.label'}}
+ {{t 'form.status.label'}}
+ {{t 'titles.actions'}}
+
+
<:body as |B|>
-
-
-
-
-
- {{B.data.user.displayName}}
-
-
- {{B.data.target.displayName}}
-
-
-
-
-
-
-
-
- {{#if (can 'cancel session' B.data)}}
-
+
+
- {{/if}}
-
-
+
+
+ {{data.user.displayName}}
+
+
+ {{data.target.displayName}}
+
+
+
+
+
+
+
+
+ {{#if (can 'cancel session' data)}}
+
+ {{/if}}
+
+
+ {{/each}}
`[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);
+ });
+ },
+ );
});
diff --git a/ui/admin/tests/acceptance/sessions/selectors.js b/ui/admin/tests/acceptance/sessions/selectors.js
new file mode 100644
index 000000000..702833b5d
--- /dev/null
+++ b/ui/admin/tests/acceptance/sessions/selectors.js
@@ -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';