Ideas:
- Store resources in redux following the JSONAPI resource spec.
- Provide actions and thunks for managing resources similar to what Ember Data Models support.
Let's take an example of a building that has floorplans.
const state = {
resources: {
building: { // <-- collection of all buildings, keyed by id
data: {
[id] {
data: { // <-- a building's data
id: '123',
type: 'building',
attributes: {
title: 'Building'
},
relationships: {
floorplans: [ // <-- related floorplans
{ type: 'floorplan', id: 'abc' }, // <-- identifier
],
},
},
meta: { // <-- all resources need this meta
changedAttributes: {
title: { // <-- indexed history of changes
history: ['Oldest change', 'Current Change', 'Newest Change'],
currentIndex: 1,
}
},
changedRelationships: {
floorplans: {
history: [
[
{ type: 'floorplan', id: 'abc' },
{ type: 'floorplan', id: 'def' } // <-- added one
],
[] // <-- removed all
],
currentIndex: -1, // <-- use the original
}
},
isDeleted,
isEmpty,
isError,
isLoaded,
isLoading,
isNew,
isReloading,
isSaving,
isValid,
createdAt,
loadedAt,
savedAt,
validationErrors: {
title: [
{ message: 'Title must be awesome' },
{ message: 'Title must be at least 12 blargles' },
{ message: 'Title cannot contain any brambles' }
]
}
},
},
},
meta: { // <-- all collections need this meta
pageSets: {
[queryKey]: { // <-- paginated sets, keyed by query
data: {
[pageNum]: {
data: [{ type: 'building', id: '123' }],
meta: {
pageNum,
isLoading,
loadedAt,
}
}
},
meta: { // <-- all paginated sets need this meta
limit,
offset,
query,
total,
},
},
},
},
},
floorplan: { // <-- collection of all floorplans, keyed by id
data: {
[id]: {
data: {
id: 'abc',
type: 'floorplan',
attributes: {
title: 'Floorplan'
},
relationships: {
building: { type: 'building', id: '123' }, // <-- one relationship
},
},
meta,
}
},
meta: {
pageSets,
}
}
}
}There are three main object types: Collection, Resource and ResourceIdentifier.
A Collection is where we store resources of the same type. The meta for a collection allows for paginated sets of resources, keyed by query. Regardless of how a resource was initially fetched, it should be stored in the proper collection.
const collection = {
data: {
[resource.id]: resource
},
meta: {
pageSets,
}
}There are many use cases for fetching paginated sets of resources from the server. The pageSets construct provides a standardized shape for storing these query sets. It is designed to allow for multiple arbitrary sets.
queryKey- an arbitrary identifier for thepageSet. One common way of creating thequeryKeyis toJSON.stringify(query). It is equally valid to use a name identifier, like "list".
Each individual page in the set contains a data and a meta. The data holds an array of resource indentifiers.
const page = {
data: [{ type, id }],
meta: {
pageNum,
isLoading,
loadedAt,
}
}
const pageSets = {
[queryKey]: {
data: {
[page.meta.pageNum]: page
},
meta: {
limit, // <-- num per page
offset, // <-- tracks the current page
query,
total, // <-- total record (reported by API)
},
}
}A Resource is closely modeled after a JSONAPI resource. The meta for a resource is closely modeled on an Ember Data Model.
Every resource will have a data and a meta. The data holds the resource itself and the meta holds information about the resource.
The data for a resource always follows a strict shape:
id- required a string identifier for the resourcetype- required a string typeattributes- an object of key/values. It is preferred to have attribute objects be shallow (avoid nesting objects).relationships- an object of keys containing one or many resource identifiers.
const resource = {
data: {
id,
type,
attributes: {
[name]: value
},
relationships: {
[one]: { type, id, meta }
[many]: [{ type, id, meta }]
}
},
meta: {
changedAttributes,
changedRelationships,
isDeleted,
isEmpty,
isError,
isLoaded,
isLoading,
isNew,
isReloading,
isSaving,
isValid,
createdAt,
loadedAt,
savedAt,
validationErrors,
}
}Change history for each individual attribute or relationship.
history- array of values for the attribute/relationship.currentIndex- which of the values in history are considered current. By default it should be the most recent item in history. IfcurrentIndexis-1then the active value should be the one stored indata.attributes[name].
const changedAttributes = {
[name]: {
history: [value],
currentIndex,
}
}
const changedRelationships = {
[name]: {
history: [value],
currentIndex,
}
}isDeleted- The resource is soft-deleted. It should be hidden in the UI in most circumstances. It will be destroyed onsave.commithas no effect on this property. (SeeisDeleted)isEmpty- New resource with no attributes or relationships. (SeeisEmpty)isError- The API returned an error other than a validation error. (SeeisError)isLoaded- The resource has been successfully retrieved from the API. (SeeisLoaded)isLoading- The resource is being retrieved from the API. (SeeisLoading)isNew- The resource was created locally and has not been saved. (SeeisNew)isReloading- The resource is being reloaded from the API. (SeeisReloading)isSaving- The resource is being saved to the API. (SeeisSaving)isValid- The resource has been successfully saved to the API and no validation errors were reported. (SeeisValid)
NOTE: Ember Data maintains a hasDirtyAttributes boolean to indicate that the resource has unsaved changes. This can be inferred by inspecting isDeleted, changedRelationships and changedAttributes.
createdAt- The timestamp when the resource was created locally (if it was created)loadedAt- The timestamp when the resource was loaded from the API (if it was loaded)savedAt- The timestamp when the resource was last successfully saved to the API (if it was saved)
A ResourceIdentifier is a minimal representation if a resource. It must contain a type and an id. These two values make it possible to retrieve any resource from its collection in state or load it from the API.
const resourceIdentifier = { type, id }We want to provide a suite of common actions for any resource inspired by the methods available to an Ember Data Model.
loadPageSet({ type, queryKey, query?, limit, offset })clearPageSet({ type, queryKey })clearPageSets({ type })clearResources({ type })
loadNextPage({ type, queryKey })- loads and advances to the next page. Will not load a page past the reportedtotal.loadPrevPage({ type, queryKey })loadFirstPage({ type, queryKey })loadLastPage({ type, queryKey })nextPage({ type, queryKey })- advances to the next page if it is already loaded. Will not attempt to load a page.prevPage({ type, queryKey })firstPage({ type, queryKey })lastPage({ type, queryKey })
create({ type, id, attributes, relationships })- create a new resource.idandtypeare required.attributesandrelationshipsare optional.receive({ type, id, attributes, relationships })- adds the resource to the store. Completely replaces the resourcedataand resets thechangedAttributesandchangedRelationships. Typically called byload(andreload).unload({ type, id })- remove the resource from the store (but doesn't delete or destroy it)delete({ type, id })- marks the resource as deleted (but doesn't save to server).undelete({ type, id })- marks the resource as not deleted (but doesn't save to server).destroy({ type, id })- marks the record as deleted, deletes the resource from the server and then unloads it from the storeload({ type, id })- loads a resource from the API. Resets all metadata.reload({ type, id })- grabs a fresh version of a resource from the API. Resets change history. Attempts to preserve other metadata.commit({ type, id })- merge thechangedAttributesinto thedata.attributes(andchangedRelationships) but does not save to server.rollback({ type, id })- clears thechangedAttributes,changedRelationshipsandisDeleted. Willunloada record if itisNew.save({ type, id })- commits any changes and saves to the server. Will also destroy any record marked as deleted.
changeAttribute({ type, id, name, value }, commit)- Slices thehistoryarray to thecurrentIndex, pushes thevalueinto the history stack and sets thecurrentIndex. Passingcommitas the optional second argument bypasses history and sets the value directly indata.attributes(and clears history).undoAttribute({ type, id, name })- decrease thecurrentIndexfor the change history.redoAttribute({ type, id, name })- increase thecurrentIndexfor the change history.resetAttribute({ type, id, name })- sets thecurrentIndexto-1for the change history.setAttributeHistoryIndex({ type, id, name, index })- sets thecurrentIndexfor the for the change history to the providedindex.rollbackAttribute({ type, id, name })- Clears history for a given attribute.rollbackAttributes({ type, id })- Clears the history for all attributes.
toggleAttribute({ type, id, name })assignAttributes({ type, id, name, attributes })- Conceptually similar toObject.assign. In reality it callschangeAttributefor each key of the providedattributes.
changeRelationship({ type, id, name, identifier?, indentifiers? }, commit)undoRelationship({ type, id, name })redoRelationship({ type, id, name })resetRelationship({ type, id, name })setRelationshipHistoryIndex({ type, id, name, index })rollbackRelationship({ type, id, name })rollbackRelationships({ type, id })
pushRelationship({ type, id, name, identifier }, commit)- Convenience thunk. For array relationships, pushes a new relationship onto the end of the array. Same as callingchangeRelationshipwith a newidentifiersarray.removeRelationship({ type, id, name, identifier }, commit)