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 thequeryKey
is 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. IfcurrentIndex
is-1
then 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
.commit
has 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.id
andtype
are required.attributes
andrelationships
are optional.receive({ type, id, attributes, relationships })
- adds the resource to the store. Completely replaces the resourcedata
and resets thechangedAttributes
andchangedRelationships
. 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 thechangedAttributes
into thedata.attributes
(andchangedRelationships
) but does not save to server.rollback({ type, id })
- clears thechangedAttributes
,changedRelationships
andisDeleted
. Willunload
a 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 thehistory
array to thecurrentIndex
, pushes thevalue
into the history stack and sets thecurrentIndex
. Passingcommit
as the optional second argument bypasses history and sets the value directly indata.attributes
(and clears history).undoAttribute({ type, id, name })
- decrease thecurrentIndex
for the change history.redoAttribute({ type, id, name })
- increase thecurrentIndex
for the change history.resetAttribute({ type, id, name })
- sets thecurrentIndex
to-1
for the change history.setAttributeHistoryIndex({ type, id, name, index })
- sets thecurrentIndex
for 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 callschangeAttribute
for 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 callingchangeRelationship
with a newidentifiers
array.removeRelationship({ type, id, name, identifier }, commit)