import Resource from './resource'; export const NO_RESOURCEFUL_ENDPOINT_ERROR = ` No resourceful endpoint tied to this resource collection. You should use resourcefulEndpoint.browse(), or supply a ResourcefulEndpoint instance when creating this collection. `; export const NO_NEXT_PAGE_ERROR = 'No next page'; export const NO_PREVIOUS_PAGE_ERROR = 'No previous page'; /** * A pagination aware collection of {@link Resource} objects. * * This class should not be used directly, but should instead be obtained * from {@link ResourcefulEndpoint} methods * * @param {Object} rawData - Raw JSON response from the Sequoia endpoint * @param {string} initialCriteria - the initial filter criteria used when requesting from this endpoint * @param {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEdnpoint} that created this ResourceCollection * * @property {Object} rawData - Raw JSON response from the Sequoia endpoint * @property {string} initialCriteria - the initial filter criteria used when requesting from this endpoint * @property {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEdnpoint} that created this ResourceCollection * @property {Resource[]} collection - Array of {@link Resource} objects * * @since 0.0.2 * * @private */ class ResourceCollection { /** * @param {Object} rawData - Raw JSON response from the Sequoia endpoint * @param {string} initialCriteria - the initial filter criteria used when requesting from this endpoint * @param {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEndpoint} that created this ResourceCollection * * @returns {Resource} */ constructor(rawData, initialCriteria, resourcefulEndpoint) { /** @property {string} - the initial filter criteria used when requesting from this endpoint */ this.initialCriteria = initialCriteria; /** @property {ResourcefulEndpoint} - The {@link ResourcefulEndpoint} that created this ResourceCollection */ this.resourcefulEndpoint = resourcefulEndpoint; /** @property {Resource[]} - Array of {@link Resource} objects */ this.collection = []; this.setData(rawData); } /** * Updates `rawData` with the json returned from the Sequoia service and sets * `collection` to an array of `{@link Resource}s * * Linked resources are collated into the individual {@link Resource}s created * for each item in the collection if they have relationship info. e.g. * Assets that are linked to Contents will have a `contentRef` - if this is present, * each Content instance will have a linked.assets[] with only the related Assets * * If there is no relationship specified in linked resources, (e.g. linked Customers * against Subscriptions from the Payment service) then each Subscription instance * will have *all* of the Customers avaliable as linked.customers[] * * @param {Object} rawData - json from a Sequoia `browse` request * * @private * * @returns {ResourceCollection} - self */ setData(rawData) { this.rawData = rawData; // Without a descriptor, we don't know truly know what to pick out if ( this.resourcefulEndpoint && this.rawData && this.rawData[this.resourcefulEndpoint.pluralName] ) { this.collection = this.rawData[this.resourcefulEndpoint.pluralName].map( (content) => { const resource = Object.assign({}, content); // Copy if (this.rawData.linked) { const linkByRefName = `${this.resourcefulEndpoint.singularName}Ref`; resource.linked = {}; Object.keys(this.rawData.linked).forEach((link) => { resource.linked[link] = this.rawData.linked[link].filter((item) => { // If a `linkByRefName` exists on this link, filter only those // that are directly linked (this helps when `browsing()` a list of // disparate content where links come back for every item in the list // (e.g. metadata/contents -> linked assets). // However, some resourcefuls have links without this explicitly set // e.g. relationships like credits/relatedMaterials.assets that have // a through, or payment/subscriptions -> linked customers if (item[linkByRefName]) { return item[linkByRefName] === resource.ref; } const relationship = (this.resourcefulEndpoint && this.resourcefulEndpoint.resourceful && this.resourcefulEndpoint.resourceful.relationships && this.resourcefulEndpoint.resourceful.relationships[link]) || {}; const { through, filterName } = relationship; // if we do not have a through, we are handling payment/customers // and we don't want to filter anything out if (!through || !filterName) return true; const { fieldNamePath } = this.resourcefulEndpoint.resourceful.relationships[through]; let throughFields = resource[fieldNamePath] || []; throughFields = Array.isArray(throughFields) ? throughFields : [throughFields]; // Our first attempt at getting the filter we need relied on what filters were in // this.resourcefulEndpoint.filters. This worked fine for metadata/credits, but // metadata/people does not have withContentRef. The following code is not pretty, // but as the filters follow the same naming convention, deriving the filter we need // from the filterName works fine. let parsedFilterName = filterName.slice(4); parsedFilterName = `${parsedFilterName .charAt(0) .toLowerCase()}${parsedFilterName.slice(1)}`; return throughFields.some( field => field === item[parsedFilterName] ); }); }); } return new Resource(resource, this.resourcefulEndpoint); } ); } return this; } /** * Current page in the pagination set * @type {number} */ get page() { return this.rawData.meta.page; } /** * Number of items per page in the pagination set * @type {number} */ get perPage() { return this.rawData.meta.perPage; } /** * Total number of Resources in the catalogue matching our criteria * @type {number} */ get totalCount() { if (this.rawData && this.rawData.meta && this.rawData.meta.totalCount) { return this.rawData.meta.totalCount; } return this.collection.length; } /** * Indicates whether there is next page of results available * * @returns true - if there is a next page */ hasNextPage() { return !!this.rawData.meta.next || !!this.rawData.meta.continue; } /** * Fetch the next page of results * * @see {@link ResourceCollection#fetch} * * @returns {Promise} */ nextPage() { if (this.rawData.meta.next) { return this.fetch(this.rawData.meta.next); } if (this.rawData.meta.continue) { return this.fetch(this.rawData.meta.continue); } return Promise.reject(NO_NEXT_PAGE_ERROR); } /** * Fetch the previous page of results * * @see {@link ResourceCollection#fetch} * * @returns {Promise} */ previousPage() { if (this.rawData.meta.prev) { return this.fetch(this.rawData.meta.prev); } return Promise.reject(NO_PREVIOUS_PAGE_ERROR); } /** * Fetch the first page of results * * @see {@link ResourceCollection#fetch} * * @returns {Promise} */ firstPage() { return this.fetch(this.rawData.meta.first); } /** * Fetch the last page of results * * @see {@link ResourceCollection#fetch} * * @returns {Promise} */ lastPage() { return this.fetch(this.rawData.meta.last); } /** * Fetch a specific page of results * * @param {number} pageNumber - the number of the page to fetch * * @todo Boundary checking? * @todo This could likely be easier to use with the initialCriteria * * @see {@link ResourceCollection#fetch} * * @returns {Promise} */ getPage(pageNumber) { return this.fetch( this.rawData.meta.first.replace(/&page=\d+/, `&page=${pageNumber}`) ); } /** * Update the collection with new data from the server. This will * also return the new ResourceCollection as a convenience. * * @param {string?} newCriteria - query string criteria to send to {@link ResourcefulEndpoint#browse} * * @private * * @returns {Promise} - The new {@link ResourceCollection} just fetched */ fetch(newCriteria) { let criteria = this.initialCriteria || ''; if (newCriteria) { criteria = newCriteria; } // Remove everything before the query string (meta.first etc all have the full path // before the quesy string, which we don't want) and the owner param as the ResourcefulEndpoint // will add that criteria = criteria.replace(/^.+\?/, '').replace(/&?owner=[^&]+/, ''); if (this.resourcefulEndpoint) { return this.resourcefulEndpoint.browse(criteria).then((newResource) => { this.rawData = newResource.rawData; this.collection = newResource.collection; return newResource; }); } return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR); } /** * Get a JSON representation of this collection's keys/values * * @returns {Object} */ toJSON() { return this.collection.map(resource => resource.toJSON()); } /** * Get a stringified version of this {@link ResourceCollection} that is suitable * for saving to sequoia. * * Simply wraps the JSON of the resource collection as an array in the <pluralName>[] property * * @returns {string} */ serialise() { return JSON.stringify( { [this.resourcefulEndpoint.pluralName]: this.toJSON() }, null, ' ' ); } /** * Get an array of ResourceCollections populated with a maximum of the `size` * paramater {@link Resource}s in each. * * Sequoia has limits to how many Resources can be saved at once. This method is used * internally by {@link ResourceCollection#save} and {@link ResourceCollection#destroy} * to send the right amount of data. * * @param {number} [size={@link ResourcefulEndpoint#batchSize}] - The number of {@link Resource}s to return in each ResourceCollection * * @see {@link ResourcefulEndpoint#all} * * @returns {ResourceCollection[]} */ explode(size) { const batchSize = size !== undefined ? size : this.resourcefulEndpoint.batchSize; const numberOfPages = Math.ceil(this.totalCount / batchSize); return Array.from(new Array(numberOfPages), (x, i) => this.resourcefulEndpoint.newResourceCollection( this.collection .slice(i * batchSize, i * batchSize + batchSize) .map(r => r.toJSON()) )); } /** * Save (create or update) (POST/PUT) all the {@link Resource}s in this * ResourceCollection. * If the ResourceCollection has more items than the batchSize specified in the * descriptor (or supplied batchSize), multiple calls will be made to the backend * * @param {number} [size={@link ResourcefulEndpoint#batchSize}] - The number of {@link Resource}s to save in each request * * @see {@link ResourceCollection#explode} * @see {@link ResourcefulEndpoint#store} * * @returns {Promise} */ save(batchSize) { if (this.resourcefulEndpoint) { return Promise.all( this.explode(batchSize).map(c => this.resourcefulEndpoint.store(c).catch((e) => { // Add the current collection to the error to allow // end users to inspect where the error occurred e.collection = c; throw e; })) ); } return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR); } /** * Validate all the {@link Resource}s in this collection. * * @example * resourceCollection.validate().catch((resource) => { * // Show resource.errors[] * }).then(resourceCollection => resourceCollection.save()) * .then(() => { * // do something on successfully saving * }); * * * @see {@link Resource#validateField} * * @returns {Promise} */ validate() { return Promise.all( this.collection.map(resource => resource.validate()) ).then(() => this); } /** * Destroy (DELETE) all the {@link Resource}s in this ResourceCollection. * If the ResourceCollection has more items than the batchSize specified in the * descriptor, multiple calls will be made to the backend * * @example <caption>Destroy all content that went out of availability this year</caption> * contents.all(where(field("availabilityEndAt").lessThan("2017"))).then(resources => resources.destroy()); * * @see {@link ResourceCollection#explode} * @see {@link ResourcefulEndpoint#destroy} * * @returns {Promise} */ destroy() { if (this.resourcefulEndpoint) { return Promise.all( this.explode().map(c => this.resourcefulEndpoint.destroy( c.collection.map(r => r.ref).join(',') )) ); } return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR); } // Pragma Local collection methods /** * Add a {@link Resource} to the [local collection]{@link ResourceCollection#collection} * Note: this method does not implement any uniqueness constraints on the `ref`s of * objects/Resources being added. If this is required, use {@link ResourceCollection#findOrCreate} * * @param {Object|Resource} data - Can be either an existing Resource or a JSON object * to create a new Resource from * * @see {@link ResourcefulEndpoint#newResource} * * @returns {!Resource} */ add(data) { let resource = data; if (!(data instanceof Resource)) { resource = this.resourcefulEndpoint.newResource(data); } this.collection.push(resource); return resource; } /** * Remove a {@link Resource} from the [local collection]{@link ResourceCollection#collection} * Will return the found {@link Resource} or null if it does not exist * * @param {string} ref - the ref of the resource to remove * * @returns {?Resource} */ remove(ref) { const index = this.collection.findIndex(r => r.ref === ref); if (index === -1) { return null; } const [resource] = this.collection.splice(index, 1); return resource; } /** * Returns a {@link Resource} if it exists in the [local collection]{@link ResourceCollection#collection} * with the supplied `ref` * * @param {string} ref - The ref of the resource to find * * @returns {?Resource} */ find(ref) { return this.collection.find(r => r.ref === ref); } /** * Find a resource in the [local collection]{@link ResourceCollection#collection} or create (and add * to the local collection) a Resource from the supplied object * * @param {Resource|Object} - the resource to find or create * * @returns {!Resource} */ findOrCreate(resource) { const existingResource = this.findWhere( resource instanceof Resource ? resource.toJSON() : resource ); if (existingResource) { return existingResource; } return this.add(resource); } /** * Filter on `ref` when a string is supplied. * Filter by a custom function by supplying a `function` * Or filter by key/value pairs in an Object * @typedef {(string|function|Object)} Criteria */ /** * Find a Resource in the [local collection]{@link ResourceCollection#collection} or `null` if not found * The below examples assume the variable contents is populated with the * result of `client.service('metadata').then(s => s.resourcefulEndpoint('contents').all())` * * @param {Criteria} - The criteria to use for filtering the local collection * * @example <caption>Find all with a custom filter function</caption> * // Find all of the Resources that have a title including 'die hard' that haven't had any tags applied yet * contents.where(r => r.title.toLowerCase().includes('die hard') && !Array.isArray(r.tags)) * * @example <caption>Find all that match a given object</caption> * // Find all of the Resources that are active and have a type of 'show' * contents.where({ type: 'show', active: true }) * * @see {@link ResourceCollection#where} * * @returns {Resource[]} */ where(criteria) { let callback; if (typeof criteria === 'function') { callback = criteria; } else if (typeof criteria === 'object' && criteria !== null) { // Short circuit when we have a ref: if (criteria.hasOwnProperty('ref') && criteria.ref !== undefined) { callback = r => r.ref === criteria.ref; } else { callback = r => Object.keys(criteria).reduce((acc, key) => { if (acc === true) { return r[key] === criteria[key]; } return false; }, true); } } else { return []; } return this.collection.filter(callback); } /** * Find a Resource in the [local collection]{@link ResourceCollection#collection} or `null` if not found * * @param {Criteria} - See {@link ResourceCollection#where} * * @see {@link ResourceCollection#where} * * @returns {?Resource} */ findWhere(criteria) { const resources = this.where(criteria); if (resources.length) { return resources[0]; } return null; } } export default ResourceCollection;