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';