Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active July 23, 2021 20:08
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save heygrady/f18341546c80964935c78439e2cd6bae to your computer and use it in GitHub Desktop.
Save heygrady/f18341546c80964935c78439e2cd6bae to your computer and use it in GitHub Desktop.

Redux JSONAPI Resources

Ideas:

  1. Store resources in redux following the JSONAPI resource spec.
  2. 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,
      }
    }
  }
}

Core concepts in the resource shape

There are three main object types: Collection, Resource and ResourceIdentifier.

Collection

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,
  }
}

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 the pageSet. One common way of creating the queryKey is to JSON.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)
    },
  }
}

Resource

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 resource
  • type - required a string type
  • attributes - 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,
  }
}

changedAttributes and changedRelationships

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. If currentIndex is -1 then the active value should be the one stored in data.attributes[name].
const changedAttributes = {
  [name]: {
    history: [value],
    currentIndex,
  }
}

const changedRelationships = {
  [name]: {
    history: [value],
    currentIndex,
  }
}

Resource meta booleans

  • isDeleted - The resource is soft-deleted. It should be hidden in the UI in most circumstances. It will be destroyed on save. commit has no effect on this property. (See isDeleted)
  • isEmpty - New resource with no attributes or relationships. (See isEmpty)
  • isError - The API returned an error other than a validation error. (See isError)
  • isLoaded - The resource has been successfully retrieved from the API. (See isLoaded)
  • isLoading - The resource is being retrieved from the API. (See isLoading)
  • isNew - The resource was created locally and has not been saved. (See isNew)
  • isReloading - The resource is being reloaded from the API. (See isReloading)
  • isSaving - The resource is being saved to the API. (See isSaving)
  • isValid - The resource has been successfully saved to the API and no validation errors were reported. (See isValid)

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.

Resource meta timestamps

  • 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)

ResourceIndentifier

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 }

Common Actions

We want to provide a suite of common actions for any resource inspired by the methods available to an Ember Data Model.

Collection Actions

  • loadPageSet({ type, queryKey, query?, limit, offset })
  • clearPageSet({ type, queryKey })
  • clearPageSets({ type })
  • clearResources({ type })

PageSet Actions

  • loadNextPage({ type, queryKey }) - loads and advances to the next page. Will not load a page past the reported total.
  • 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 })

Resource Actions

  • create({ type, id, attributes, relationships }) - create a new resource. id and type are required. attributes and relationships are optional.
  • receive({ type, id, attributes, relationships }) - adds the resource to the store. Completely replaces the resource data and resets the changedAttributes and changedRelationships. Typically called by load (and reload).
  • 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 store
  • load({ 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 the changedAttributes into the data.attributes (and changedRelationships) but does not save to server.
  • rollback({ type, id }) - clears the changedAttributes, changedRelationships and isDeleted. Will unload a record if it isNew.
  • save({ type, id }) - commits any changes and saves to the server. Will also destroy any record marked as deleted.

Resource Attributes Actions

  • changeAttribute({ type, id, name, value }, commit) - Slices the history array to the currentIndex, pushes the value into the history stack and sets the currentIndex. Passing commit as the optional second argument bypasses history and sets the value directly in data.attributes (and clears history).
  • undoAttribute({ type, id, name }) - decrease the currentIndex for the change history.
  • redoAttribute({ type, id, name }) - increase the currentIndex for the change history.
  • resetAttribute({ type, id, name }) - sets the currentIndex to -1 for the change history.
  • setAttributeHistoryIndex({ type, id, name, index }) - sets the currentIndex for the for the change history to the provided index.
  • rollbackAttribute({ type, id, name }) - Clears history for a given attribute.
  • rollbackAttributes({ type, id }) - Clears the history for all attributes.

Helper Resource Attributes Actions

  • toggleAttribute({ type, id, name })
  • assignAttributes({ type, id, name, attributes }) - Conceptually similar to Object.assign. In reality it calls changeAttribute for each key of the provided attributes.

Resource Relationships Actions

  • 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 })

Helper Resource Relationships Actions

  • pushRelationship({ type, id, name, identifier }, commit) - Convenience thunk. For array relationships, pushes a new relationship onto the end of the array. Same as calling changeRelationship with a new identifiers array.
  • removeRelationship({ type, id, name, identifier }, commit)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment