diff --git a/changelog/24103.txt b/changelog/24103.txt new file mode 100644 index 000000000000..f86bfd996949 --- /dev/null +++ b/changelog/24103.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Sort list view of entities and aliases alphabetically using the item name +``` diff --git a/ui/app/routes/vault/cluster/access/identity/aliases/index.js b/ui/app/routes/vault/cluster/access/identity/aliases/index.js index e34db172471f..67491ac83d65 100644 --- a/ui/app/routes/vault/cluster/access/identity/aliases/index.js +++ b/ui/app/routes/vault/cluster/access/identity/aliases/index.js @@ -18,6 +18,7 @@ export default Route.extend(ListRoute, { responsePath: 'data.keys', page: params.page, pageFilter: params.pageFilter, + sortBy: 'name', }) .catch((err) => { if (err.httpStatus === 404) { diff --git a/ui/app/routes/vault/cluster/access/identity/index.js b/ui/app/routes/vault/cluster/access/identity/index.js index 61a3941ebf1e..ed1ff84ad4d4 100644 --- a/ui/app/routes/vault/cluster/access/identity/index.js +++ b/ui/app/routes/vault/cluster/access/identity/index.js @@ -18,6 +18,7 @@ export default Route.extend(ListRoute, { responsePath: 'data.keys', page: params.page, pageFilter: params.pageFilter, + sortBy: 'name', }) .catch((err) => { if (err.httpStatus === 404) { diff --git a/ui/app/serializers/identity/_base.js b/ui/app/serializers/identity/_base.js index 4e6aa31e5515..97c2b0e72801 100644 --- a/ui/app/serializers/identity/_base.js +++ b/ui/app/serializers/identity/_base.js @@ -9,6 +9,10 @@ import ApplicationSerializer from '../application'; export default ApplicationSerializer.extend({ normalizeItems(payload) { if (payload.data.keys && Array.isArray(payload.data.keys)) { + if (typeof payload.data.keys[0] !== 'string') { + // If keys is not an array of strings, it was already normalized into objects in extractLazyPaginatedData + return payload.data.keys; + } return payload.data.keys.map((key) => { const model = payload.data.key_info[key]; model.id = key; diff --git a/ui/app/serializers/identity/entity-alias.js b/ui/app/serializers/identity/entity-alias.js index 4d0b141b6ba2..419f3c3d2bdb 100644 --- a/ui/app/serializers/identity/entity-alias.js +++ b/ui/app/serializers/identity/entity-alias.js @@ -4,4 +4,15 @@ */ import IdentitySerializer from './_base'; -export default IdentitySerializer.extend(); +export default IdentitySerializer.extend({ + extractLazyPaginatedData(payload) { + return payload.data.keys.map((key) => { + const model = payload.data.key_info[key]; + model.id = key; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + }, +}); diff --git a/ui/app/serializers/identity/entity.js b/ui/app/serializers/identity/entity.js index 9df4537f2890..00763251dd23 100644 --- a/ui/app/serializers/identity/entity.js +++ b/ui/app/serializers/identity/entity.js @@ -12,4 +12,14 @@ export default IdentitySerializer.extend(EmbeddedRecordsMixin, { attrs: { aliases: { embedded: 'always' }, }, + extractLazyPaginatedData(payload) { + return payload.data.keys.map((key) => { + const model = payload.data.key_info[key]; + model.id = key; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + }, }); diff --git a/ui/app/services/store.js b/ui/app/services/store.js index 79fe55e599e6..f3497eae57af 100644 --- a/ui/app/services/store.js +++ b/ui/app/services/store.js @@ -11,6 +11,7 @@ import { assert } from '@ember/debug'; import { set, get } from '@ember/object'; import clamp from 'vault/utils/clamp'; import config from 'vault/config/environment'; +import sortObjects from 'vault/utils/sort-objects'; const { DEFAULT_PAGE_SIZE } = config.APP; @@ -184,11 +185,12 @@ export default class StoreService extends Store { // store data cache as { response, dataset} // also populated `lazyCaches` attribute storeDataset(modelName, query, response, array) { - const dataSet = { + const dataset = query.sortBy ? sortObjects(array, query.sortBy) : array; + const value = { response, - dataset: array, + dataset, }; - this.setLazyCacheForModel(modelName, query, dataSet); + this.setLazyCacheForModel(modelName, query, value); } clearDataset(modelName) { diff --git a/ui/app/utils/sort-objects.js b/ui/app/utils/sort-objects.js new file mode 100644 index 000000000000..5ade25803e70 --- /dev/null +++ b/ui/app/utils/sort-objects.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export default function sortObjects(array, key) { + if (Array.isArray(array) && array?.every((e) => e[key] && typeof e[key] === 'string')) { + return array.sort((a, b) => { + // ignore upper vs lowercase + const valueA = a[key].toUpperCase(); + const valueB = b[key].toUpperCase(); + if (valueA < valueB) return -1; + if (valueA > valueB) return 1; + return 0; + }); + } + // if not sortable, return original array + return array; +} diff --git a/ui/tests/unit/utils/sort-objects-test.js b/ui/tests/unit/utils/sort-objects-test.js new file mode 100644 index 000000000000..40ccfb8f1062 --- /dev/null +++ b/ui/tests/unit/utils/sort-objects-test.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import sortObjects from 'vault/utils/sort-objects'; +import { module, test } from 'qunit'; + +module('Unit | Utility | sort-objects', function () { + test('it sorts array of objects', function (assert) { + const originalArray = [ + { foo: 'grape', bar: 'third' }, + { foo: 'banana', bar: 'second' }, + { foo: 'lemon', bar: 'fourth' }, + { foo: 'apple', bar: 'first' }, + ]; + const expectedArray = [ + { bar: 'first', foo: 'apple' }, + { bar: 'second', foo: 'banana' }, + { bar: 'third', foo: 'grape' }, + { bar: 'fourth', foo: 'lemon' }, + ]; + + assert.propEqual(sortObjects(originalArray, 'foo'), expectedArray, 'it sorts array of objects'); + + const originalWithNumbers = [ + { foo: 'Z', bar: 'fourth' }, + { foo: '1', bar: 'first' }, + { foo: '2', bar: 'second' }, + { foo: 'A', bar: 'third' }, + ]; + const expectedWithNumbers = [ + { bar: 'first', foo: '1' }, + { bar: 'second', foo: '2' }, + { bar: 'third', foo: 'A' }, + { bar: 'fourth', foo: 'Z' }, + ]; + assert.propEqual( + sortObjects(originalWithNumbers, 'foo'), + expectedWithNumbers, + 'it sorts strings with numbers and letters' + ); + }); + + test('it disregards capitalization', function (assert) { + // sort() arranges capitalized values before lowercase, the helper removes case by making all strings toUppercase() + const originalArray = [ + { foo: 'something-a', bar: 'third' }, + { foo: 'D-something', bar: 'second' }, + { foo: 'SOMETHING-b', bar: 'fourth' }, + { foo: 'a-something', bar: 'first' }, + ]; + const expectedArray = [ + { bar: 'first', foo: 'a-something' }, + { bar: 'second', foo: 'D-something' }, + { bar: 'third', foo: 'something-a' }, + { bar: 'fourth', foo: 'SOMETHING-b' }, + ]; + + assert.propEqual( + sortObjects(originalArray, 'foo'), + expectedArray, + 'it sorts array of objects regardless of capitalization' + ); + }); + + test('it fails gracefully', function (assert) { + const originalArray = [ + { foo: 'b', bar: 'two' }, + { foo: 'a', bar: 'one' }, + ]; + assert.propEqual( + sortObjects(originalArray, 'someKey'), + originalArray, + 'it returns original array if key does not exist' + ); + assert.deepEqual(sortObjects('not an array'), 'not an array', 'it returns original arg if not an array'); + + const notStrings = [ + { foo: '1', bar: 'third' }, + { foo: 'Z', bar: 'second' }, + { foo: 1, bar: 'fourth' }, + { foo: 2, bar: 'first' }, + ]; + assert.propEqual( + sortObjects(notStrings, 'foo'), + notStrings, + 'it returns original array if values are not all strings' + ); + }); +});