UI: Ember-data upgrade 5.3.2 prep: use custom service instead of extending ember-data store (#28695)

* rename store to pagination, remove store extension

* initial update of service test

* remove superfluous helper

* replace store with pagination service in main app

* update kmip engine syntax

* add pagination to kmip engine

* update to pagination in config-ui engine

* update sync engine to use pagination service

* use pagination service in kv engine

* use pagination service in ldap engine

* use pagination in pki engine

* update renaming clearDataset functions

* link to jira VAULT-31721

* remove comment
This commit is contained in:
claire bontempo 2024-10-17 10:00:57 -07:00 committed by GitHub
parent f2041b00e5
commit 1fbbf9d76b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 481 additions and 429 deletions

View File

@ -21,6 +21,7 @@ export default class App extends Application {
'namespace',
{ 'app-router': 'router' },
'store',
'pagination',
'version',
'custom-messages',
],
@ -60,6 +61,7 @@ export default class App extends Application {
'path-help',
{ 'app-router': 'router' },
'store',
'pagination',
'version',
'secret-mount-path',
],
@ -78,7 +80,14 @@ export default class App extends Application {
},
ldap: {
dependencies: {
services: [{ 'app-router': 'router' }, 'store', 'secret-mount-path', 'flash-messages', 'auth'],
services: [
{ 'app-router': 'router' },
'store',
'pagination',
'secret-mount-path',
'flash-messages',
'auth',
],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
},
@ -95,6 +104,7 @@ export default class App extends Application {
{ 'app-router': 'router' },
'secret-mount-path',
'store',
'pagination',
'version',
],
externalRoutes: {
@ -114,6 +124,7 @@ export default class App extends Application {
{ 'app-router': 'router' },
'secret-mount-path',
'store',
'pagination',
'version',
],
externalRoutes: {
@ -125,7 +136,7 @@ export default class App extends Application {
},
sync: {
dependencies: {
services: ['flash-messages', 'flags', { 'app-router': 'router' }, 'store', 'version'],
services: ['flash-messages', 'flags', { 'app-router': 'router' }, 'store', 'pagination', 'version'],
externalRoutes: {
kvSecretOverview: 'vault.cluster.secrets.backend.kv.secret.index',
clientCountOverview: 'vault.cluster.clients',

View File

@ -28,7 +28,7 @@ export default Component.extend({
console: service(),
router: service(),
controlGroup: service(),
store: service(),
pagination: service(),
'data-test-component': 'console/ui-panel',
attributeBindings: ['data-test-component'],
@ -108,7 +108,7 @@ export default Component.extend({
const currentRoute = owner.lookup(`router:main`).currentRouteName;
try {
this.store.clearDataset();
this.pagination.clearDataset();
yield this.router.transitionTo(currentRoute);
this.logAndOutput(null, { type: 'success', content: 'The current screen has been refreshed!' });
} catch (error) {

View File

@ -26,13 +26,13 @@ import { tracked } from '@glimmer/tracking';
export default class GeneratedItemList extends Component {
@service router;
@service store;
@service pagination;
@tracked itemToDelete = null;
@action
refreshItemList() {
const route = getOwner(this).lookup(`route:${this.router.currentRouteName}`);
this.store.clearDataset();
this.pagination.clearDataset();
route.refresh();
}
}

View File

@ -37,6 +37,7 @@ const VALID_TYPES_BY_PROVIDER = {
azurekeyvault: ['rsa-2048', 'rsa-3072', 'rsa-4096'],
};
export default class KeymgmtDistribute extends Component {
@service pagination;
@service store;
@service flashMessages;
@service router;
@ -191,7 +192,7 @@ export default class KeymgmtDistribute extends Component {
.then(() => {
this.flashMessages.success(`Successfully distributed key ${key} to ${provider}`);
// update keys on provider model
this.store.clearDataset('keymgmt/key');
this.pagination.clearDataset('keymgmt/key');
const providerModel = this.store.peekRecord('keymgmt/provider', provider);
providerModel.fetchKeys(providerModel.keys?.meta?.currentPage || 1);
this.args.onClose();

View File

@ -5,10 +5,12 @@
import Mixin from '@ember/object/mixin';
import removeRecord from 'vault/utils/remove-record';
import { service } from '@ember/service';
// removes Ember Data records from the cache when the model
// changes or you move away from the current route
export default Mixin.create({
store: service(),
modelPath: 'model',
unloadModel() {
const { modelPath } = this;

View File

@ -44,7 +44,7 @@ const validations = {
@withModelValidations(validations)
export default class KeymgmtProviderModel extends Model {
@service store;
@service pagination;
@attr('string') backend;
@attr('string', {
label: 'Provider name',
@ -128,7 +128,7 @@ export default class KeymgmtProviderModel extends Model {
} else {
// try unless capabilities returns false
try {
this.keys = await this.store.lazyPaginatedQuery('keymgmt/key', {
this.keys = await this.pagination.lazyPaginatedQuery('keymgmt/key', {
backend: this.backend,
provider: this.name,
responsePath: 'data.keys',

View File

@ -8,12 +8,12 @@ import ListRoute from 'core/mixins/list-route';
import { service } from '@ember/service';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
model(params) {
const itemType = this.modelFor('vault.cluster.access.identity');
const modelType = `identity/${itemType}-alias`;
return this.store
return this.pagination
.lazyPaginatedQuery(modelType, {
responsePath: 'data.keys',
page: params.page,
@ -38,12 +38,12 @@ export default Route.extend(ListRoute, {
willTransition(transition) {
window.scrollTo(0, 0);
if (!transition || transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -8,12 +8,12 @@ import ListRoute from 'core/mixins/list-route';
import { service } from '@ember/service';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
model(params) {
const itemType = this.modelFor('vault.cluster.access.identity');
const modelType = `identity/${itemType}`;
return this.store
return this.pagination
.lazyPaginatedQuery(modelType, {
responsePath: 'data.keys',
page: params.page,
@ -38,12 +38,12 @@ export default Route.extend(ListRoute, {
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -9,6 +9,7 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default Route.extend({
pagination: service(),
store: service(),
queryParams: {
@ -26,7 +27,7 @@ export default Route.extend({
const prefix = params.prefix || '';
if (this.modelFor('vault.cluster.access.leases').canList) {
return hash({
leases: this.store
leases: this.pagination
.lazyPaginatedQuery('lease', {
prefix,
responsePath: 'data.keys',
@ -104,7 +105,7 @@ export default Route.extend({
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},

View File

@ -9,7 +9,7 @@ import { singularize } from 'ember-inflector';
import ListRoute from 'vault/mixins/list-route';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
pathHelp: service('path-help'),
getMethodAndModelInfo() {
@ -25,7 +25,7 @@ export default Route.extend(ListRoute, {
const { page, pageFilter } = this.paramsFor(this.routeName);
const modelType = `generated-${singularize(itemType)}-${type}`;
return this.store
return this.pagination
.lazyPaginatedQuery(modelType, {
responsePath: 'data.keys',
page: page,
@ -46,12 +46,12 @@ export default Route.extend(ListRoute, {
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -8,6 +8,7 @@ import Route from '@ember/routing/route';
import UnloadModel from 'vault/mixins/unload-model-route';
export default Route.extend(UnloadModel, {
pagination: service(),
store: service(),
queryParams: {
@ -27,7 +28,7 @@ export default Route.extend(UnloadModel, {
model(params) {
if (this.version.hasNamespaces) {
return this.store
return this.pagination
.lazyPaginatedQuery('namespace', {
responsePath: 'data.keys',
page: Number(params?.page) || 1,
@ -74,12 +75,12 @@ export default Route.extend(UnloadModel, {
willTransition(transition) {
window.scrollTo(0, 0);
if (!transition || transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -9,7 +9,7 @@ import ClusterRoute from 'vault/mixins/cluster-route';
import ListRoute from 'core/mixins/list-route';
export default Route.extend(ClusterRoute, ListRoute, {
store: service(),
pagination: service(),
version: service(),
shouldReturnEmptyModel(policyType, version) {
@ -21,7 +21,7 @@ export default Route.extend(ClusterRoute, ListRoute, {
if (this.shouldReturnEmptyModel(policyType, this.version)) {
return;
}
return this.store
return this.pagination
.lazyPaginatedQuery(`policy/${policyType}`, {
page: params.page,
pageFilter: params.pageFilter,
@ -65,12 +65,12 @@ export default Route.extend(ClusterRoute, ListRoute, {
willTransition(transition) {
window.scrollTo(0, 0);
if (!transition || transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -30,6 +30,7 @@ function getValidPage(pageParam) {
}
export default Route.extend({
pagination: service(),
store: service(),
templateName: 'vault/cluster/secrets/backend/list',
pathHelp: service('path-help'),
@ -131,7 +132,7 @@ export default Route.extend({
return hash({
secret,
secrets: this.store
secrets: this.pagination
.lazyPaginatedQuery(modelType, {
id: secret,
backend,
@ -163,7 +164,7 @@ export default Route.extend({
const has404 = this.has404;
// only clear store cache if this is a new model
if (secret !== controller?.baseKey?.id) {
this.store.clearDataset();
this.pagination.clearDataset();
}
controller.set('hasModel', true);
controller.setProperties({
@ -220,12 +221,12 @@ export default Route.extend({
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -3,9 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Store, { CacheHandler } from '@ember-data/store';
import RequestManager from '@ember-data/request';
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
import Service, { service } from '@ember/service';
import { schedule } from '@ember/runloop';
import { resolve, Promise } from 'rsvp';
import { dasherize } from '@ember/string';
@ -17,10 +15,6 @@ import sortObjects from 'vault/utils/sort-objects';
const { DEFAULT_PAGE_SIZE } = config.APP;
export function normalizeModelName(modelName) {
return dasherize(modelName);
}
export function keyForCache(query) {
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
// we want to ignore size, page, responsePath, and pageFilter in the cacheKey
@ -34,16 +28,8 @@ export function keyForCache(query) {
return JSON.stringify(cacheKeyObject);
}
export default class StoreService extends Store {
requestManager = new RequestManager();
constructor(args) {
super(args);
// If at some point we no longer need an extended store, we can remove the @ember-data/legacy-compat dep
// See: https://api.emberjs.com/ember-data/4.12/modules/@ember-data%2Frequest
this.requestManager.use([LegacyNetworkHandler]);
this.requestManager.useCache(CacheHandler);
}
export default class Pagination extends Service {
@service store;
lazyCaches = new Map();
@ -51,7 +37,7 @@ export default class StoreService extends Store {
const cacheKey = keyForCache(key);
const cache = this.lazyCacheForModel(modelName) || new Map();
cache.set(cacheKey, value);
const modelKey = normalizeModelName(modelName);
const modelKey = dasherize(modelName);
this.lazyCaches.set(modelKey, cache);
}
@ -64,7 +50,7 @@ export default class StoreService extends Store {
}
lazyCacheForModel(modelName) {
return this.lazyCaches.get(normalizeModelName(modelName));
return this.lazyCaches.get(dasherize(modelName));
}
// This is the public interface for the store extension - to be used just
@ -83,8 +69,8 @@ export default class StoreService extends Store {
const skipCache = query.skipCache;
// We don't want skipCache to be part of the actual query key, so remove it
delete query.skipCache;
const adapter = this.adapterFor(modelType);
const modelName = normalizeModelName(modelType);
const adapter = this.store.adapterFor(modelType);
const modelName = dasherize(modelType);
const dataCache = skipCache ? this.clearDataset(modelName) : this.getDataset(modelName, query);
const responsePath = query.responsePath;
assert('responsePath is required', responsePath);
@ -97,9 +83,9 @@ export default class StoreService extends Store {
return resolve(this.fetchPage(modelName, query));
}
return adapter
.query(this, { modelName }, query, null, adapterOptions)
.query(this.store, { modelName }, query, null, adapterOptions)
.then((response) => {
const serializer = this.serializerFor(modelName);
const serializer = this.store.serializerFor(modelName);
const datasetHelper = serializer.extractLazyPaginatedData;
const dataset = datasetHelper
? datasetHelper.call(serializer, response)
@ -162,20 +148,16 @@ export default class StoreService extends Store {
// pushes records into the store and returns the result
fetchPage(modelName, query) {
const response = this.constructResponse(modelName, query);
this.unloadAll(modelName);
this.store.unloadAll(modelName);
return new Promise((resolve) => {
// push subset of records into the store
schedule('destroy', () => {
this.push(
this.serializerFor(modelName).normalizeResponse(
this,
this.modelFor(modelName),
response,
null,
'query'
)
this.store.push(
this.store
.serializerFor(modelName)
.normalizeResponse(this.store, this.store.modelFor(modelName), response, null, 'query')
);
const model = this.peekAll(modelName).slice();
const model = this.store.peekAll(modelName).slice();
model.set('meta', response.meta);
resolve(model);
});

View File

@ -1,10 +1,10 @@
# Client-side pagination
Our custom extended `store` service allows us to paginate LIST responses while maintaining good performance, particularly when the LIST response includes tens of thousands of keys in the data response. It does this by caching the entire response, and then filtering the full response into the datastore for the client.
Our custom `pagination` service allows us to paginate LIST responses while maintaining good performance, particularly when the LIST response includes tens of thousands of keys in the data response. It does this by caching the entire response, and then filtering the full response into the datastore for the client. It was originally a custom method in our `store` service that extended the ember-data `store` but now is it's own `pagination` service.
## Using pagination
Rather than use `store.query`, use `store.lazyPaginatedQuery`. It generally uses the same inputs, but accepts additional keys in the query object `size`, `page`, `responsePath`, `pageFilter`
Rather than use `store.query`, use `pagination.lazyPaginatedQuery`. It generally uses the same inputs, but accepts additional keys in the query object `size`, `page`, `responsePath`, `pageFilter`
### Before
@ -23,12 +23,12 @@ export default class ExampleRoute extends Route {
```js
export default class ExampleRoute extends Route {
@service store;
@service pagination;
model(params) {
const { page, pageFilter, secret } = params;
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
return this.store.lazyPaginatedQuery('secret', {
return this.pagination.lazyPaginatedQuery('secret', {
backend,
id: secret,
size,
@ -47,11 +47,11 @@ In order to interrupt the regular serialization when using `lazyPaginatedData`,
## Gotchas
The data is cached from whenever the original API call is made, which means that if a user views a list and then creates or deletes an item, viewing the list page again will show outdated information unless the cache for the item is cleared first. For this reason, it is best practice to clear the dataset with `store.clearDataset(modelName)` after successfully deleting or creating an item.
The data is cached from whenever the original API call is made, which means that if a user views a list and then creates or deletes an item, viewing the list page again will show outdated information unless the cache for the item is cleared first. For this reason, it is best practice to clear the dataset with `pagination.clearDataset(modelName)` after successfully deleting or creating an item.
## How it works
When using the `lazyPaginatedQuery` method, the full response is cached in a [tracked Map](https://github.com/tracked-tools/tracked-built-ins/tree/master) within the service. `store.lazyCaches` is actually a Map of [Maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), keyed first on the normalized modelType and then on a stringified version of the base query (all keys except ones related to pagination). So, at the top level `store.lazyCaches` looks like this:
When using the `lazyPaginatedQuery` method, the full response is cached in a [tracked Map](https://github.com/tracked-tools/tracked-built-ins/tree/master) within the service. `pagination.lazyCaches` is actually a Map of [Maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), keyed first on the normalized modelType and then on a stringified version of the base query (all keys except ones related to pagination). So, at the top level `pagination.lazyCaches` looks like this:
```
lazyCaches = new Map({
@ -61,7 +61,7 @@ lazyCaches = new Map({
})
```
Within each top-level modelType, we need to separate cached responses based on the details of the query. Typically (but not always) this includes the backend name. In list items that can be nested (see KV V2 secrets or namespaces for example) `id` is also provided, so that the keys nested under the given ID is returned. The store.lazyCaches may look something like the following after a user navigates to a couple different KV v2 lists, and clicks into the `app/` item:
Within each top-level modelType, we need to separate cached responses based on the details of the query. Typically (but not always) this includes the backend name. In list items that can be nested (see KV V2 secrets or namespaces for example) `id` is also provided, so that the keys nested under the given ID is returned. The pagination.lazyCaches may look something like the following after a user navigates to a couple different KV v2 lists, and clicks into the `app/` item:
```
lazyCaches = new Map({

View File

@ -25,6 +25,7 @@ import { isAfter } from 'date-fns';
export default class MessagesList extends Component {
@service('app-router') router;
@service store;
@service pagination;
@service flashMessages;
@service customMessages;
@service namespace;
@ -75,7 +76,7 @@ export default class MessagesList extends Component {
const { isNew } = this.args.message;
const { id, title } = yield this.args.message.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} ${title} message.`);
this.store.clearDataset('config-ui/message');
this.pagination.clearDataset('config-ui/message');
this.customMessages.fetchMessages(this.namespace.path);
this.router.transitionTo('vault.cluster.config-ui.messages.message.details', id);
}

View File

@ -19,17 +19,17 @@ import errorMessage from 'vault/utils/error-message';
*/
export default class MessageDetails extends Component {
@service store;
@service('app-router') router;
@service flashMessages;
@service customMessages;
@service namespace;
@service pagination;
@action
async deleteMessage() {
try {
this.store.clearDataset('config-ui/message');
await this.args.message.destroyRecord(this.args.message.id);
this.pagination.clearDataset('config-ui/message');
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${this.args.message.title}.`);

View File

@ -23,11 +23,11 @@ import errorMessage from 'vault/utils/error-message';
*/
export default class MessagesList extends Component {
@service store;
@service('app-router') router;
@service customMessages;
@service flashMessages;
@service namespace;
@service customMessages;
@service pagination;
@service('app-router') router;
@tracked showMaxMessageModal = false;
@tracked messageToDelete = null;
@ -90,8 +90,8 @@ export default class MessagesList extends Component {
@task
*deleteMessage(message) {
try {
this.store.clearDataset('config-ui/message');
yield message.destroyRecord(message.id);
this.pagination.clearDataset('config-ui/message');
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${message.title}.`);

View File

@ -16,7 +16,16 @@ export default class ConfigUiEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['auth', 'store', 'flash-messages', 'namespace', 'app-router', 'version', 'custom-messages'],
services: [
'auth',
'store',
'pagination',
'flash-messages',
'namespace',
'app-router',
'version',
'custom-messages',
],
};
}

View File

@ -8,7 +8,7 @@ import { service } from '@ember/service';
import { hash } from 'rsvp';
export default class MessagesRoute extends Route {
@service store;
@service pagination;
queryParams = {
page: {
@ -38,7 +38,7 @@ export default class MessagesRoute extends Route {
if (status === 'active') active = true;
if (status === 'inactive') active = false;
const messages = this.store
const messages = this.pagination
.lazyPaginatedQuery('config-ui/message', {
authenticated,
pageFilter: filter,

View File

@ -35,12 +35,12 @@ export default Mixin.create({
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -5,15 +5,14 @@
import Engine from 'ember-engines/engine';
import loadInitializers from 'ember-load-initializers';
import Resolver from './resolver';
import Resolver from 'ember-resolver';
import config from './config/environment';
const { modulePrefix } = config;
/* eslint-disable ember/avoid-leaking-state-in-ember-objects */
const Eng = Engine.extend({
modulePrefix,
Resolver,
dependencies: {
export default class KmipEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: [
'auth',
'download',
@ -22,13 +21,12 @@ const Eng = Engine.extend({
'path-help',
'app-router',
'store',
'pagination',
'version',
'secret-mount-path',
],
externalRoutes: ['secrets'],
},
});
};
}
loadInitializers(Eng, modulePrefix);
export default Eng;
loadInitializers(KmipEngine, modulePrefix);

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Resolver from 'ember-resolver';
export default Resolver;

View File

@ -8,7 +8,7 @@ import ListRoute from 'core/mixins/list-route';
import { service } from '@ember/service';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
secretMountPath: service(),
credParams() {
const { role_name: role, scope_name: scope } = this.paramsFor('credentials');
@ -19,7 +19,7 @@ export default Route.extend(ListRoute, {
},
model(params) {
const { role, scope } = this.credParams();
return this.store
return this.pagination
.lazyPaginatedQuery('kmip/credential', {
role,
scope,

View File

@ -8,7 +8,7 @@ import ListRoute from 'core/mixins/list-route';
import { service } from '@ember/service';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
secretMountPath: service(),
pathHelp: service(),
scope() {
@ -18,7 +18,7 @@ export default Route.extend(ListRoute, {
return this.pathHelp.hydrateModel('kmip/role', this.secretMountPath.currentPath);
},
model(params) {
return this.store
return this.pagination
.lazyPaginatedQuery('kmip/role', {
backend: this.secretMountPath.currentPath,
scope: this.scope(),

View File

@ -8,10 +8,10 @@ import { service } from '@ember/service';
import ListRoute from 'core/mixins/list-route';
export default Route.extend(ListRoute, {
store: service(),
pagination: service(),
secretMountPath: service(),
model(params) {
return this.store
return this.pagination
.lazyPaginatedQuery('kmip/scope', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
@ -31,12 +31,12 @@ export default Route.extend(ListRoute, {
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearDataset();
this.pagination.clearDataset();
}
return true;
},
reload() {
this.store.clearDataset();
this.pagination.clearDataset();
this.refresh();
},
},

View File

@ -27,7 +27,7 @@ import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
export default class KvListPageComponent extends Component {
@service flashMessages;
@service('app-router') router;
@service store;
@service pagination;
@tracked secretPath;
@tracked metadataToDelete = null; // set to the metadata intended to delete
@ -57,7 +57,7 @@ export default class KvListPageComponent extends Component {
try {
// The model passed in is a kv/metadata model
await model.destroyRecord();
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.pagination.clearDataset('kv/metadata'); // Clear out the pagination cache so that the metadata/list view is updated.
const message = `Successfully deleted the metadata and all version data of the secret ${model.fullSecretPath}.`;
this.flashMessages.success(message);
// if you've deleted a secret from within a directory, transition to its parent directory.

View File

@ -39,6 +39,7 @@ export default class KvSecretMetadataDetails extends Component {
@service flashMessages;
@service('app-router') router;
@service store;
@service pagination;
@tracked error = null;
@tracked customMetadataFromData = null;
@ -54,7 +55,7 @@ export default class KvSecretMetadataDetails extends Component {
const adapter = this.store.adapterFor('kv/metadata');
try {
await adapter.deleteMetadata(backend, path);
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.pagination.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success(
`Successfully deleted the metadata and all version data for the secret ${path}.`
);

View File

@ -29,7 +29,7 @@ export default class KvSecretCreate extends Component {
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@service store;
@service pagination;
@tracked showJsonView = false;
@tracked errorMessage;
@ -60,7 +60,7 @@ export default class KvSecretCreate extends Component {
try {
// try saving secret data first
yield secret.save();
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.pagination.clearDataset('kv/metadata'); // Clear out the pagination cache so that the metadata/list view is updated.
this.flashMessages.success(`Successfully saved secret data for: ${secret.path}.`);
} catch (error) {
let message = errorMessage(error);

View File

@ -25,6 +25,7 @@ export default class KvEngine extends Engine {
'app-router',
'secret-mount-path',
'store',
'pagination',
'version',
],
externalRoutes: ['secrets', 'syncDestination'],

View File

@ -9,7 +9,7 @@ import { hash } from 'rsvp';
import { pathIsDirectory, breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretsListRoute extends Route {
@service store;
@service pagination;
@service('app-router') router;
@service secretMountPath;
@ -23,7 +23,7 @@ export default class KvSecretsListRoute extends Route {
};
async fetchMetadata(backend, pathToSecret, params) {
return await this.store
return await this.pagination
.lazyPaginatedQuery('kv/metadata', {
backend,
responsePath: 'data.keys',

View File

@ -15,7 +15,7 @@ import type LdapRoleModel from 'vault/models/ldap/role';
import { Breadcrumb, ValidationMap } from 'vault/vault/app-types';
import type FlashMessageService from 'vault/services/flash-messages';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
interface Args {
model: LdapRoleModel;
@ -31,7 +31,7 @@ interface RoleTypeOption {
export default class LdapCreateAndEditRolePageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@tracked modelValidations: ValidationMap | null = null;
@tracked invalidFormMessage = '';
@ -71,7 +71,7 @@ export default class LdapCreateAndEditRolePageComponent extends Component<Args>
yield model.save();
this.flashMessages.success(`Successfully ${action} the role ${model.name}`);
if (action === 'created') {
this.store.clearDataset('ldap/role');
this.pagination.clearDataset('ldap/role');
}
this.router.transitionTo(
'vault.cluster.secrets.backend.ldap.roles.role.details',

View File

@ -14,7 +14,7 @@ import type LdapRoleModel from 'vault/models/ldap/role';
import { Breadcrumb } from 'vault/vault/app-types';
import type FlashMessageService from 'vault/services/flash-messages';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
interface Args {
model: LdapRoleModel;
@ -24,14 +24,14 @@ interface Args {
export default class LdapRoleDetailsPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@action
async delete() {
try {
await this.args.model.destroyRecord();
this.flashMessages.success('Role deleted successfully.');
this.store.clearDataset('ldap/role');
this.pagination.clearDataset('ldap/role');
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles');
} catch (error) {
const message = errorMessage(error, 'Unable to delete role. Please try again or contact support.');

View File

@ -14,7 +14,7 @@ import type SecretEngineModel from 'vault/models/secret-engine';
import type FlashMessageService from 'vault/services/flash-messages';
import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import { tracked } from '@glimmer/tracking';
interface Args {
@ -28,7 +28,7 @@ interface Args {
export default class LdapRolesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@tracked credsToRotate: LdapRoleModel | null = null;
@tracked roleToDelete: LdapRoleModel | null = null;
@ -64,7 +64,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
try {
const message = `Successfully deleted role ${model.name}.`;
await model.destroyRecord();
this.store.clearDataset('ldap/role');
this.pagination.clearDataset('ldap/role');
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles');
this.flashMessages.success(message);
} catch (error) {

View File

@ -14,7 +14,7 @@ export default class LdapEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['app-router', 'store', 'secret-mount-path', 'flash-messages', 'auth'],
services: ['app-router', 'store', 'pagination', 'secret-mount-path', 'flash-messages', 'auth'],
externalRoutes: ['secrets'],
};
}

View File

@ -9,6 +9,7 @@ import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Transition from '@ember/routing/transition';
import type LdapRoleModel from 'vault/models/ldap/role';
@ -36,6 +37,7 @@ interface LdapRolesRouteParams {
@withConfig('ldap/config')
export default class LdapRolesRoute extends Route {
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@service declare readonly secretMountPath: SecretMountPath;
declare promptConfig: boolean;
@ -54,7 +56,7 @@ export default class LdapRolesRoute extends Route {
return hash({
backendModel,
promptConfig: this.promptConfig,
roles: this.store.lazyPaginatedQuery(
roles: this.pagination.lazyPaginatedQuery(
'ldap/role',
{
backend: backendModel.id,

View File

@ -25,6 +25,7 @@ export default class PkiEngine extends Engine {
'app-router',
'secret-mount-path',
'store',
'pagination',
'version',
],
externalRoutes: ['secrets', 'secretsListRootConfiguration', 'externalMountIssuer'],

View File

@ -11,8 +11,9 @@ import { getCliMessage } from 'pki/routes/overview';
@withConfig()
export default class PkiCertificatesIndexRoute extends Route {
@service store;
@service pagination;
@service secretMountPath;
@service store; // used by @withConfig decorator
queryParams = {
page: {
@ -23,7 +24,7 @@ export default class PkiCertificatesIndexRoute extends Route {
async fetchCertificates(params) {
try {
const page = Number(params.page) || 1;
return await this.store.lazyPaginatedQuery('pki/certificate/base', {
return await this.pagination.lazyPaginatedQuery('pki/certificate/base', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
page,

View File

@ -7,12 +7,12 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PkiIssuersListRoute extends Route {
@service store;
@service pagination;
@service secretMountPath;
model(params) {
const page = Number(params.page) || 1;
return this.store
return this.pagination
.lazyPaginatedQuery('pki/issuer', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',

View File

@ -11,8 +11,9 @@ import { PKI_DEFAULT_EMPTY_STATE_MSG } from 'pki/routes/overview';
@withConfig()
export default class PkiKeysIndexRoute extends Route {
@service pagination;
@service secretMountPath;
@service store;
@service store; // used by @withConfig decorator
queryParams = {
page: {
@ -25,7 +26,7 @@ export default class PkiKeysIndexRoute extends Route {
return hash({
hasConfig: this.pkiMountHasConfig,
parentModel: this.modelFor('keys'),
keyModels: this.store
keyModels: this.pagination
.lazyPaginatedQuery('pki/key', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',

View File

@ -10,8 +10,9 @@ import { hash } from 'rsvp';
import { getCliMessage } from 'pki/routes/overview';
@withConfig()
export default class PkiRolesIndexRoute extends Route {
@service store;
@service store; // used by @withConfig decorator
@service secretMountPath;
@service pagination;
queryParams = {
page: {
@ -22,7 +23,7 @@ export default class PkiRolesIndexRoute extends Route {
async fetchRoles(params) {
try {
const page = Number(params.page) || 1;
return await this.store.lazyPaginatedQuery('pki/role', {
return await this.pagination.lazyPaginatedQuery('pki/role', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
page,

View File

@ -10,7 +10,7 @@ import errorMessage from 'vault/utils/error-message';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type FlashMessageService from 'vault/services/flash-messages';
interface Args {
@ -19,7 +19,7 @@ interface Args {
export default class DestinationsTabsToolbar extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@service declare readonly flashMessages: FlashMessageService;
@action
@ -28,7 +28,7 @@ export default class DestinationsTabsToolbar extends Component<Args> {
const { destination } = this.args;
const message = `Destination ${destination.name} has been queued for deletion.`;
await destination.destroyRecord();
this.store.clearDataset('sync/destination');
this.pagination.clearDataset('sync/destination');
this.router.transitionTo('vault.cluster.sync.secrets.overview');
this.flashMessages.success(message);
} catch (error) {

View File

@ -14,7 +14,7 @@ import { next } from '@ember/runloop';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type FlashMessageService from 'vault/services/flash-messages';
import type { EngineOwner } from 'vault/vault/app-types';
import type { SyncDestinationName, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
@ -28,7 +28,7 @@ interface Args {
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@service declare readonly flashMessages: FlashMessageService;
@tracked destinationToDelete: SyncDestinationModel | null = null;
@ -98,7 +98,7 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
const { name } = destination;
const message = `Destination ${name} has been queued for deletion.`;
await destination.destroyRecord();
this.store.clearDataset('sync/destination');
this.pagination.clearDataset('sync/destination');
this.router.transitionTo('vault.cluster.sync.secrets.overview');
this.flashMessages.success(message);
} catch (error) {

View File

@ -15,7 +15,7 @@ import type SyncDestinationModel from 'vault/models/sync/destination';
import { ValidationMap } from 'vault/vault/app-types';
import type FlashMessageService from 'vault/services/flash-messages';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
interface Args {
destination: SyncDestinationModel;
@ -24,7 +24,7 @@ interface Args {
export default class DestinationsCreateForm extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@tracked modelValidations: ValidationMap | null = null;
@tracked invalidFormMessage = '';
@ -95,7 +95,7 @@ export default class DestinationsCreateForm extends Component<Args> {
// if the user then attempts to update the record the credential will get overwritten with the masked placeholder value
// since the record will be fetched from the details route we can safely unload it to avoid the aforementioned issue
destination.unloadRecord();
this.store.clearDataset('sync/destination');
this.pagination.clearDataset('sync/destination');
}
this.router.transitionTo(
'vault.cluster.sync.secrets.destinations.destination.details',

View File

@ -13,7 +13,7 @@ import errorMessage from 'vault/utils/error-message';
import SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncAssociationModel from 'vault/vault/models/sync/association';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type FlashMessageService from 'vault/services/flash-messages';
import type { EngineOwner } from 'vault/vault/app-types';
@ -24,7 +24,7 @@ interface Args {
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@service declare readonly flashMessages: FlashMessageService;
@tracked secretToUnsync: SyncAssociationModel | null = null;
@ -41,7 +41,7 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
@action
refreshRoute() {
// refresh route to update displayed secrets
this.store.clearDataset('sync/association');
this.pagination.clearDataset('sync/association');
this.router.transitionTo(
'vault.cluster.sync.secrets.destinations.destination.secrets',
this.args.destination.type,

View File

@ -14,6 +14,7 @@ import errorMessage from 'vault/utils/error-message';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type FlashMessageService from 'vault/services/flash-messages';
import type { SearchSelectOption } from 'vault/vault/app-types';
@ -25,6 +26,7 @@ export default class DestinationSyncPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly pagination: PaginationService;
constructor(owner: unknown, args: Args) {
super(owner, args);
@ -46,7 +48,7 @@ export default class DestinationSyncPageComponent extends Component<Args> {
}
willDestroy(): void {
this.store.clearDataset('sync/association');
this.pagination.clearDataset('sync/association');
super.willDestroy();
}

View File

@ -14,7 +14,7 @@ export default class SyncEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['flash-messages', 'flags', 'app-router', 'store', 'version'],
services: ['flash-messages', 'flags', 'app-router', 'store', 'pagination', 'version'],
externalRoutes: ['kvSecretOverview', 'clientCountOverview'],
};
}

View File

@ -7,7 +7,7 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncAssociationModel from 'vault/vault/models/sync/association';
import type Controller from '@ember/controller';
@ -27,7 +27,7 @@ interface SyncDestinationSecretsController extends Controller {
}
export default class SyncDestinationSecretsRoute extends Route {
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
queryParams = {
page: {
@ -39,7 +39,7 @@ export default class SyncDestinationSecretsRoute extends Route {
const destination = this.modelFor('secrets.destinations.destination') as SyncDestinationModel;
return hash({
destination,
associations: this.store.lazyPaginatedQuery('sync/association', {
associations: this.pagination.lazyPaginatedQuery('sync/association', {
responsePath: 'data.keys',
page: Number(params.page) || 1,
destinationType: destination.type,

View File

@ -7,7 +7,7 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type RouterService from '@ember/routing/router-service';
import type { ModelFrom } from 'vault/vault/route';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
@ -33,7 +33,7 @@ interface SyncSecretsDestinationsController extends Controller {
}
export default class SyncSecretsDestinationsIndexRoute extends Route {
@service declare readonly store: StoreService;
@service declare readonly pagination: PaginationService;
@service('app-router') declare readonly router: RouterService;
queryParams = {
@ -73,7 +73,7 @@ export default class SyncSecretsDestinationsIndexRoute extends Route {
async model(params: SyncSecretsDestinationsIndexRouteParams) {
const { name, type, page } = params;
return hash({
destinations: this.store.lazyPaginatedQuery('sync/destination', {
destinations: this.pagination.lazyPaginatedQuery('sync/destination', {
page: Number(page) || 1,
pageFilter: (dataset: Array<SyncDestinationModel>) => this.filterData(dataset, name, type),
responsePath: 'data.keys',

View File

@ -47,7 +47,7 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function (
assert.expect(3);
const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
const clearDatasetStub = sinon.stub(this.store, 'clearDataset');
const clearDatasetStub = sinon.stub(this.owner.lookup('service:pagination'), 'clearDataset');
this.server.delete('/sys/sync/destinations/aws-sm/us-west-1', () => {
assert.ok(true, 'Request made to delete destination');

View File

@ -56,7 +56,7 @@ module('Integration | Component | sync | Page::Destinations', function (hooks) {
};
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.clearDatasetStub = sinon.stub(store, 'clearDataset');
this.clearDatasetStub = sinon.stub(this.owner.lookup('service:pagination'), 'clearDataset');
});
test('it should render header and tabs', async function (assert) {

View File

@ -24,7 +24,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.clearDatasetStub = sinon.stub(this.store, 'clearDataset');
this.clearDatasetStub = sinon.stub(this.owner.lookup('service:pagination'), 'clearDataset');
this.renderFormComponent = () => {
return render(hbs` <Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />`, {

View File

@ -140,7 +140,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio
});
test('it should clear sync associations from store in willDestroy hook', async function (assert) {
const clearDatasetStub = sinon.stub(this.store, 'clearDataset');
const clearDatasetStub = sinon.stub(this.owner.lookup('service:pagination'), 'clearDataset');
this.renderComponent = true;
await render(

View File

@ -15,6 +15,7 @@ module('Unit | Adapter | sync | association', function (hooks) {
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.pagination = this.owner.lookup('service:pagination');
this.params = [
{ type: 'aws-sm', name: 'us-west-1' },
@ -50,7 +51,7 @@ module('Unit | Adapter | sync | association', function (hooks) {
return associationsResponse(schema, req);
});
await this.store.lazyPaginatedQuery('sync/association', {
await this.pagination.lazyPaginatedQuery('sync/association', {
responsePath: 'data.keys',
page: 1,
destinationType: 'aws-sm',

View File

@ -0,0 +1,288 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { keyForCache } from 'vault/services/pagination';
import { dasherize } from '@ember/string';
import clamp from 'vault/utils/clamp';
import config from 'vault/config/environment';
import Sinon from 'sinon';
const { DEFAULT_PAGE_SIZE } = config.APP;
module('Unit | Service | pagination', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.pagination = this.owner.lookup('service:pagination');
this.store = this.owner.lookup('service:store');
});
test('pagination.setLazyCacheForModel', function (assert) {
const modelName = 'someModel';
const key = {
id: '',
backend: 'database',
responsePath: 'data.keys',
page: 1,
pageFilter: null,
size: 15,
};
const value = {
response: {
request_id: '1eb6473c-8df0-924e-1c8d-e016a6420aee',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
keys: null,
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: 'database',
backend: 'database',
},
dataset: ['connection', 'connection2'],
};
this.pagination.setLazyCacheForModel(modelName, key, value);
const cacheEntry = this.pagination.lazyCaches.get(dasherize(modelName));
const actual = Object.fromEntries(cacheEntry); // convert from Map to Object for assertion
const expected = { '{"backend":"database","id":""}': value };
assert.propEqual(actual, expected, 'model name is dasherized and can be retrieved from lazyCache');
});
test('keyForCache', function (assert) {
const query = { id: 1 };
const queryWithSize = { id: 1, size: 1 };
assert.deepEqual(keyForCache(query), JSON.stringify(query), 'generated the correct cache key');
assert.deepEqual(keyForCache(queryWithSize), JSON.stringify(query), 'excludes size from query cache');
});
test('clamp', function (assert) {
assert.strictEqual(clamp('foo', 0, 100), 0, 'returns the min if passed a non-number');
assert.strictEqual(clamp(0, 1, 100), 1, 'returns the min when passed number is less than the min');
assert.strictEqual(clamp(200, 1, 100), 100, 'returns the max passed number is greater than the max');
assert.strictEqual(clamp(50, 1, 100), 50, 'returns the passed number when it is in range');
});
test('pagination.storeDataset', function (assert) {
const arr = ['one', 'two'];
const query = { id: 1 };
this.pagination.storeDataset('data', query, {}, arr);
assert.deepEqual(
this.pagination.getDataset('data', query).dataset,
arr,
'it stores the array as .dataset'
);
assert.deepEqual(
this.pagination.getDataset('data', query).response,
{},
'it stores the response as .response'
);
assert.ok(this.pagination.get('lazyCaches').has('data'), 'it stores model map');
assert.ok(
this.pagination.get('lazyCaches').get('data').has(keyForCache(query)),
'it stores data on the model map'
);
});
test('pagination.clearDataset with a prefix', function (assert) {
const arr = ['one', 'two'];
const arr2 = ['one', 'two', 'three', 'four'];
this.pagination.storeDataset('data', { id: 1 }, {}, arr);
this.pagination.storeDataset('transit-key', { id: 2 }, {}, arr2);
assert.strictEqual(this.pagination.get('lazyCaches').size, 2, 'it stores both keys');
this.pagination.clearDataset('transit-key');
assert.strictEqual(this.pagination.get('lazyCaches').size, 1, 'deletes one key');
assert.notOk(this.pagination.get('lazyCaches').has('transit-key'), 'cache is no longer stored');
});
test('pagination.clearDataset with no args clears entire cache', function (assert) {
const arr = ['one', 'two'];
const arr2 = ['one', 'two', 'three', 'four'];
this.pagination.storeDataset('data', { id: 1 }, {}, arr);
this.pagination.storeDataset('transit-key', { id: 2 }, {}, arr2);
assert.strictEqual(this.pagination.get('lazyCaches').size, 2, 'it stores both keys');
this.pagination.clearDataset();
assert.strictEqual(this.pagination.get('lazyCaches').size, 0, 'deletes all of the keys');
assert.notOk(this.pagination.get('lazyCaches').has('transit-key'), 'first cache key is no longer stored');
assert.notOk(this.pagination.get('lazyCaches').has('data'), 'second cache key is no longer stored');
});
test('pagination.getDataset', function (assert) {
const arr = ['one', 'two'];
this.pagination.storeDataset('data', { id: 1 }, {}, arr);
assert.deepEqual(this.pagination.getDataset('data', { id: 1 }), { response: {}, dataset: arr });
});
test('pagination.constructResponse', function (assert) {
const arr = ['one', 'two', 'three', 'fifteen', 'twelve'];
this.pagination.storeDataset('data', { id: 1 }, {}, arr);
assert.deepEqual(
this.pagination.constructResponse('data', {
id: 1,
pageFilter: 't',
page: 1,
size: 3,
responsePath: 'data',
}),
{
data: ['two', 'three', 'fifteen'],
meta: {
currentPage: 1,
lastPage: 2,
nextPage: 2,
prevPage: 1,
total: 5,
filteredTotal: 4,
pageSize: 3,
},
},
'it returns filtered results'
);
});
test('pagination.fetchPage', async function (assert) {
const keys = ['zero', 'one', 'two', 'three', 'four', 'five', 'six'];
const data = {
data: {
keys,
},
};
const pageSize = 2;
const query = {
size: pageSize,
page: 1,
responsePath: 'data.keys',
};
this.pagination.storeDataset('transit-key', query, data, keys);
let result;
result = await this.pagination.fetchPage('transit-key', query);
assert.strictEqual(result.get('length'), pageSize, 'returns the correct number of items');
assert.deepEqual(
result.map((r) => r.id),
keys.slice(0, pageSize),
'returns the first page of items'
);
assert.deepEqual(
result.get('meta'),
{
nextPage: 2,
prevPage: 1,
currentPage: 1,
lastPage: 4,
total: 7,
filteredTotal: 7,
pageSize: 2,
},
'returns correct meta values'
);
result = await this.pagination.fetchPage('transit-key', {
size: pageSize,
page: 3,
responsePath: 'data.keys',
});
const pageThreeEnd = 3 * pageSize;
const pageThreeStart = pageThreeEnd - pageSize;
assert.deepEqual(
result.map((r) => r.id),
keys.slice(pageThreeStart, pageThreeEnd),
'returns the third page of items'
);
result = await this.pagination.fetchPage('transit-key', {
size: pageSize,
page: 99,
responsePath: 'data.keys',
});
assert.deepEqual(
result.map((r) => r.id),
keys.slice(keys.length - 1),
'returns the last page when the page value is beyond the of bounds'
);
result = await this.pagination.fetchPage('transit-key', {
size: pageSize,
page: 0,
responsePath: 'data.keys',
});
assert.deepEqual(
result.map((r) => r.id),
keys.slice(0, pageSize),
'returns the first page when page value is under the bounds'
);
});
test('pagination.lazyPaginatedQuery', async function (assert) {
const response = {
data: ['foo'],
};
let queryArgs;
const adapterForStub = () => {
return {
query(store, modelName, query) {
queryArgs = query;
return Promise.resolve(response);
},
};
};
Sinon.stub(this.store, 'adapterFor').callsFake(adapterForStub);
// stub fetchPage because we test it separately
Sinon.stub(this.pagination, 'fetchPage').callsFake(() => {});
const query = { page: 1, size: 1, responsePath: 'data' };
await this.pagination.lazyPaginatedQuery('transit-key', query);
assert.deepEqual(
this.pagination.getDataset('transit-key', query),
{ response: { data: null }, dataset: ['foo'] },
'stores returned dataset'
);
await this.pagination.lazyPaginatedQuery('secret', { page: 1, responsePath: 'data' });
assert.strictEqual(queryArgs.size, DEFAULT_PAGE_SIZE, 'calls query with DEFAULT_PAGE_SIZE');
assert.throws(
() => {
this.pagination.lazyPaginatedQuery('transit-key', {});
},
/responsePath is required/,
'requires responsePath'
);
assert.throws(
() => {
this.pagination.lazyPaginatedQuery('transit-key', { responsePath: 'foo' });
},
/page is required/,
'requires page'
);
});
test('pagination.filterData', async function (assert) {
const dataset = [
{ id: 'foo', name: 'Foo', type: 'test' },
{ id: 'bar', name: 'Bar', type: 'test' },
{ id: 'bar-2', name: 'Bar', type: null },
];
const defaultFiltering = this.pagination.filterData('foo', dataset);
assert.deepEqual(defaultFiltering, [{ id: 'foo', name: 'Foo', type: 'test' }]);
const filter = (data) => {
return data.filter((d) => d.name === 'Bar' && d.type === 'test');
};
const customFiltering = this.pagination.filterData(filter, dataset);
assert.deepEqual(customFiltering, [{ id: 'bar', name: 'Bar', type: 'test' }]);
});
});

View File

@ -1,257 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { resolve } from 'rsvp';
import { run } from '@ember/runloop';
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { normalizeModelName, keyForCache } from 'vault/services/store';
import clamp from 'vault/utils/clamp';
import config from 'vault/config/environment';
const { DEFAULT_PAGE_SIZE } = config.APP;
module('Unit | Service | store', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
});
test('normalizeModelName', function (assert) {
assert.strictEqual(normalizeModelName('oneThing'), 'one-thing', 'dasherizes modelName');
});
test('keyForCache', function (assert) {
const query = { id: 1 };
const queryWithSize = { id: 1, size: 1 };
assert.deepEqual(keyForCache(query), JSON.stringify(query), 'generated the correct cache key');
assert.deepEqual(keyForCache(queryWithSize), JSON.stringify(query), 'excludes size from query cache');
});
test('clamp', function (assert) {
assert.strictEqual(clamp('foo', 0, 100), 0, 'returns the min if passed a non-number');
assert.strictEqual(clamp(0, 1, 100), 1, 'returns the min when passed number is less than the min');
assert.strictEqual(clamp(200, 1, 100), 100, 'returns the max passed number is greater than the max');
assert.strictEqual(clamp(50, 1, 100), 50, 'returns the passed number when it is in range');
});
test('store.storeDataset', function (assert) {
const arr = ['one', 'two'];
const query = { id: 1 };
this.store.storeDataset('data', query, {}, arr);
assert.deepEqual(this.store.getDataset('data', query).dataset, arr, 'it stores the array as .dataset');
assert.deepEqual(
this.store.getDataset('data', query).response,
{},
'it stores the response as .response'
);
assert.ok(this.store.get('lazyCaches').has('data'), 'it stores model map');
assert.ok(
this.store.get('lazyCaches').get('data').has(keyForCache(query)),
'it stores data on the model map'
);
});
test('store.clearDataset with a prefix', function (assert) {
const arr = ['one', 'two'];
const arr2 = ['one', 'two', 'three', 'four'];
this.store.storeDataset('data', { id: 1 }, {}, arr);
this.store.storeDataset('transit-key', { id: 2 }, {}, arr2);
assert.strictEqual(this.store.get('lazyCaches').size, 2, 'it stores both keys');
this.store.clearDataset('transit-key');
assert.strictEqual(this.store.get('lazyCaches').size, 1, 'deletes one key');
assert.notOk(this.store.get('lazyCaches').has('transit-key'), 'cache is no longer stored');
});
test('store.clearDataset with no args clears entire cache', function (assert) {
const arr = ['one', 'two'];
const arr2 = ['one', 'two', 'three', 'four'];
this.store.storeDataset('data', { id: 1 }, {}, arr);
this.store.storeDataset('transit-key', { id: 2 }, {}, arr2);
assert.strictEqual(this.store.get('lazyCaches').size, 2, 'it stores both keys');
this.store.clearDataset();
assert.strictEqual(this.store.get('lazyCaches').size, 0, 'deletes all of the keys');
assert.notOk(this.store.get('lazyCaches').has('transit-key'), 'first cache key is no longer stored');
assert.notOk(this.store.get('lazyCaches').has('data'), 'second cache key is no longer stored');
});
test('store.getDataset', function (assert) {
const arr = ['one', 'two'];
this.store.storeDataset('data', { id: 1 }, {}, arr);
assert.deepEqual(this.store.getDataset('data', { id: 1 }), { response: {}, dataset: arr });
});
test('store.constructResponse', function (assert) {
const arr = ['one', 'two', 'three', 'fifteen', 'twelve'];
this.store.storeDataset('data', { id: 1 }, {}, arr);
assert.deepEqual(
this.store.constructResponse('data', {
id: 1,
pageFilter: 't',
page: 1,
size: 3,
responsePath: 'data',
}),
{
data: ['two', 'three', 'fifteen'],
meta: {
currentPage: 1,
lastPage: 2,
nextPage: 2,
prevPage: 1,
total: 5,
filteredTotal: 4,
pageSize: 3,
},
},
'it returns filtered results'
);
});
test('store.fetchPage', async function (assert) {
const keys = ['zero', 'one', 'two', 'three', 'four', 'five', 'six'];
const data = {
data: {
keys,
},
};
const pageSize = 2;
const query = {
size: pageSize,
page: 1,
responsePath: 'data.keys',
};
this.store.storeDataset('transit-key', query, data, keys);
let result;
result = await this.store.fetchPage('transit-key', query);
assert.strictEqual(result.get('length'), pageSize, 'returns the correct number of items');
assert.deepEqual(
result.map((r) => r.id),
keys.slice(0, pageSize),
'returns the first page of items'
);
assert.deepEqual(
result.get('meta'),
{
nextPage: 2,
prevPage: 1,
currentPage: 1,
lastPage: 4,
total: 7,
filteredTotal: 7,
pageSize: 2,
},
'returns correct meta values'
);
result = await this.store.fetchPage('transit-key', {
size: pageSize,
page: 3,
responsePath: 'data.keys',
});
const pageThreeEnd = 3 * pageSize;
const pageThreeStart = pageThreeEnd - pageSize;
assert.deepEqual(
result.map((r) => r.id),
keys.slice(pageThreeStart, pageThreeEnd),
'returns the third page of items'
);
result = await this.store.fetchPage('transit-key', {
size: pageSize,
page: 99,
responsePath: 'data.keys',
});
assert.deepEqual(
result.map((r) => r.id),
keys.slice(keys.length - 1),
'returns the last page when the page value is beyond the of bounds'
);
result = await this.store.fetchPage('transit-key', {
size: pageSize,
page: 0,
responsePath: 'data.keys',
});
assert.deepEqual(
result.map((r) => r.id),
keys.slice(0, pageSize),
'returns the first page when page value is under the bounds'
);
});
test('store.lazyPaginatedQuery', function (assert) {
const response = {
data: ['foo'],
};
let queryArgs;
const store = this.owner.factoryFor('service:store').create({
adapterFor() {
return {
query(store, modelName, query) {
queryArgs = query;
return resolve(response);
},
};
},
fetchPage() {},
});
const query = { page: 1, size: 1, responsePath: 'data' };
run(function () {
store.lazyPaginatedQuery('transit-key', query);
});
assert.deepEqual(
store.getDataset('transit-key', query),
{ response: { data: null }, dataset: ['foo'] },
'stores returned dataset'
);
run(function () {
store.lazyPaginatedQuery('secret', { page: 1, responsePath: 'data' });
});
assert.strictEqual(queryArgs.size, DEFAULT_PAGE_SIZE, 'calls query with DEFAULT_PAGE_SIZE');
assert.throws(
() => {
store.lazyPaginatedQuery('transit-key', {});
},
/responsePath is required/,
'requires responsePath'
);
assert.throws(
() => {
store.lazyPaginatedQuery('transit-key', { responsePath: 'foo' });
},
/page is required/,
'requires page'
);
});
test('store.filterData', async function (assert) {
const dataset = [
{ id: 'foo', name: 'Foo', type: 'test' },
{ id: 'bar', name: 'Bar', type: 'test' },
{ id: 'bar-2', name: 'Bar', type: null },
];
const defaultFiltering = this.store.filterData('foo', dataset);
assert.deepEqual(defaultFiltering, [{ id: 'foo', name: 'Foo', type: 'test' }]);
const filter = (data) => {
return data.filter((d) => d.name === 'Bar' && d.type === 'test');
};
const customFiltering = this.store.filterData(filter, dataset);
assert.deepEqual(customFiltering, [{ id: 'bar', name: 'Bar', type: 'test' }]);
});
});

16
ui/types/vault/services/pagination.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import { RecordArray } from '@ember-data/store';
export default class PaginationService extends Service {
lazyPaginatedQuery(
modelName: string,
query: object,
options?: { adapterOptions: object }
): Promise<RecordArray>;
clearDataset(modelName: string);
}

View File

@ -3,16 +3,11 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Store, { RecordArray } from '@ember-data/store';
import Store from '@ember-data/store';
export default class StoreService extends Store {
lazyPaginatedQuery(
modelName: string,
query: object,
options?: { adapterOptions: object }
): Promise<RecordArray>;
clearDataset(modelName: string);
adapterFor(modelName: string);
createRecord(modelName: string, object);
findRecord(modelName: string, path: string);
peekRecord(modelName: string, path: string);
query(modelName: string, query: object);