0.0.72 • Published 3 years ago

@krisvr/reduxresource v0.0.72

Weekly downloads
42
License
MIT
Repository
-
Last release
3 years ago

Reduxresource

Generic functionality to handle common Redux logic in Angular through ngrx.

Warning: Not plug & play ready!

Installation is tricky. This package was created for internal use, and the docs might be too limited to get this thing up and running without support.

Use at your own discretion.

Concept

Ngrx requires a lot of code, that is often very similar (especially for CRUD style projects). There are 2 common solutions for this issue:

  • Code generators
  • Generic functions to handle common logic

As code generators still duplicate code, which would be hard to maintain, the generic functions seemed to be the best solution.

This library offers a lot of generic functionality to handle all CRUD actions with error handling and other useful meta state information regarding requests.

Even though many use cases are covered by the library, it is possible to add application specific logic, such as custom actions, reducers and effects.

Resources

ActionTypes

We built an ActionType creator function with default action types. It returns an object with ActionTypes, in contrast to the enum that is typical for ngrx ActionTypes.

Custom ActionTypes can be added in the custom ActionTypes class, preferably as static attributes of the custom ActionTypes class.

Actions

We built an Action creator function that returns default Actions for these ActionTypes. It returns an object of Action functions, in contrast to the classes that are typically used in ngrx.

Custom Actions can be added in the custom Actions class, preferably as static attributes of the custom Actions class.

These Resource Actions are configured to work with the RequestState (see “RequestState” below) automatically.

Obviously any custom ActionType will need a custom Action.

Reducer

These actions will pretty much always have the same impact on the state. F.e. adding a record will obviously add the record.

To avoid having to write this same reducer logic every time, we built a reducer creator.

We can add any custom reducer functionality in an ActionReducerMap. This is mainly for custom Actions, though it’s possible to create custom reducers for Resource Actions. Keep in mind that there currently is no way to overwrite the resource reducer, which will always be called for Resource Actions!

Selectors

Any resource will have the same state structure, so it makes sense to have common selectors for this state as well.

To achieve this, we built a selector creator function that returns an object of selectors.

Custom selectors can be added in the custom selectors file as needed.

Effects

Effects for different resources will share the same functionality as well. Obviously, an action to load a single record should make the API call to load that single record.

Any custom effects class can be extended by the resource effects class. We can then use these predefined effects in our custom effects class.

Custom effects can be added in the default ngrx way. If you don’t want to use a specific resource effect, simply don’t include it in the custom effects class.

Service

The Resource Effects contact the api through the relevant service. As the effects will always make similar calls, we built a service with common functions. These will usually be called only from the Resource Effect, though you’re free to use them in your custom effects.

Custom functions can be created in the custom service class.

Data sets

Every time we load multiple records in a request, the order and pageQuery can be different.

Examples:

  • Load all the languages sorted by name
  • Load the second page in the overview of languages, sorted by code.

Sometimes we want to use multiple data sets of the same resource type on the same page. In addition, it would be nice if we would have the option to resolve these data sets from the state.

To solve this, we introduced “data sets”. We can pass an identifier string to the loadMany actions. The id’s of the entities in the data set will then be stored in the state, in the correct order. This identifier is also used as an identifier in the resourceFreshness state, so we can resolve from state.

If no identifier is passed, loadMany will use “overview” as the default identifier.

Data set vs relationship?

When we load the containers of a page, we could either store the container ids in a page_id data set, or in the containerIds relationship of the page.

It is recommended to use the data set if you want to have a separate resource freshness for the entire data set, f.e. "the containers of the page are still fresh". If containers are only a relationship of the page, we don't have resource freshness for this set.

It is recommended to use a relationship if the data set has a hard link with the parent. F.e. we will never load containers without a page. This means we don't need a separate resource freshness for containers. Relationships are easy to use, f.e. when we create a resolver with included data (f.e. resolve a page with its containers in a single action), we can't set a dataSet for the included data, only for the main action (in our example: page, but not the containers of the page).

Tip: special dataSet for "full" record

A resolver can check the resource freshness of an entity to determine if we need to load a fresh version from the server or not.

Imagine we load a page without containers. Even though the containerIds will be returned as a relationship (all relationship ids are always returned), we will not have the actual container data included. If we then try to resolve the full page, included container data, the resolver might (depending on the resolver settings) see the page is still fresh and simply return it from the store without loading a new version from the server.

Therefore it is recommended to use pass a dataSet even to a LoadOne action, f.e. "pageWithContent_id". Then we will also store separate resource freshness for that dataset, which can be used in the resolver.

Sorting / pagination

We can pass a sortQuery and a pageQuery to LoadMany action payloads of a resource.

If these are not supplied in the payload, LoadMany will get the sortQuery and pageQuery from the state. These will usually be set in the state by pagination and sorting controls of a data table. This is useful, because in most data overview grids, we can simply let the pagination/sorting controls handle the sortQuery and pageQuery in the state, and the resource effects will automatically take care of loading fresh data based on those queries.

One thing to keep in mind is that the sortQuery in the state is not necessarily the way data is sorted on the screen, as it can be overruled by a payload sortQuery parameter.

Resolver

Most pages need some data from the api. We could dispatch a load action in every component, everytime we need some data.

However, when we want to resolve in a more advanced way (using the Universal TransferState, resolving from state while loading fresh data, etc…), this gets very cumbersome. Especially if we need the same data in multiple components.

Solution

We built a resolver function with many advanced features. We can simply create custom resolvers that call this resolver function with some parameters to determine f.e. what data we want to resolve.

The resolver will resolve this data to both the route and the state.

Features

Universal

When we use Angular Universal, the serverside rendered app will already have resolved the data. To avoid having to make the same request moments later, when the actual clientside app renders, we can pass this data from the server to the client.

This means the client doesn’t have to load the data again, and no new loading spinners will appear. On the server, there is an obvious performance improvement, as less calls are being made to the api.

Resolve from state

Usually, we wait until the data is resolved before we show the route to the user. If we already have the data in our store from previous requests, this doesn’t always make sense.

Example: the user is on an overview page, and immediately clicks through to a detail page. If we loaded this record 2 seconds ago for the overview, does it make sense to load it again? Probably not.

That’s why we added the “resolve from state” functionality to the resolver. We can specify a certain duration. If the data in the state was updated a shorter while ago than this duration, we consider the data fresh, and we can resolve from the state.

When resolving from state, we don’t send a new request to the server by default. Even though there would be no delay for the user in this case, it is still recommended to avoid sending too many requests to the server.

However, the resolveFromStateForceRequest flag allows us to always send a request. The data will still be resolved from the state, so the user will still see the data instantly. This flag will force a new load request behind the scenes. The component can start by displaying the state data, and update it as soon as the new request completes.

In this case, it’s a good idea to show some sort of indication to the user that there is still a “data update request” going on, and that the data on screen is not final. That is also why it’s not a good idea to use this for sensitive data, data that changes a lot, or populating forms. Imagine filling out a form, and halfway through the data changes again.

A note on “Resource Freshness”

To check if the data in the state is still fresh enough to resolve from, we need to store the moment every piece of data was last updated. Updated doesn’t only mean “loaded from the server”, because f.e. adding or editing a record means it will be fresh as well (though this is open for debate).

You’d think the RequestState (see: “Requeststate”) would be able to store the freshness of data, but… you’d be wrong. First of all, we don’t always store a separate state for every entity (and even though that would technically be possible, it would blow up the size of our state). More importantly, as we described above, the freshness of data doesn’t always depend on requests. Many actions that don’t necessarily trigger requests, can also update data, thereby making it “fresh” again.

We could save the freshness with every entity in the state, though this would have 2 downsides as well. First of all, it would clutter up the entity state itself, adding data that isn’t actually part of the entity itself. Every change to the data freshness would trigger the selectors, which could have a big impact on performance. Also, we don’t want to just store the freshness of entities. What if our data is an overview page? Sure, we could say every entity in the overview is fresh, but we’d also like to say our entire overview is fresh. Else, there would never be a way to resolve the entire overview from state.

Data mapping

Data coming from and going to the API follows the JSON-API format. To map this data, we use factories.

This happens mostly in the resource service, so to keep the resource generic, we always want to implement the ResourceFactory interface. This will make sure we always have the right methods to map data from and to the JSON-API format.

Id = string?

Following the json api spec, id’s going to and coming from the api are always strings. The main idea behind this is that strings are more flexible than integers. To keep this flexibility in the front, and to avoid having to convert id’s back and forth between string and number, the decision was made to use strings for all id’s.

Attributes & relationships format / Partial objects

We don’t directly add attributes to Resource objects. We use a structure similar to the json api structure. This gives us the benefits of easy mapping, and it allows us to create partial objects.

Partial objects share the same attributes and relationship data as normal resource objects, but they don’t have an id. This is useful when we add an entity, we don’t have an id yet. We could just allow the id of a full object to be undefined, though that would force us to have checks for undefined everywhere we use the id, even if we are sure we have a full entity with an id.

By defining separate models for attributes and relationships, we can easily reuse them in both the full as the partial models.

Included data (relationships)

Relationship data coming from the API is not nested, but gets passed in a separate “included” data attribute. We can automatically map this included data in the resource service, but only if the factory is listed in the factory config. When creating a new resource, we need to add the new resource factory to the factory config list.

When we load data, f.e. a Course, the languages of that course don’t really have anything to do with the “Load course” action. We could automatically dispatch separate actions for included data. However, those actions would be async, and we want the current action to only complete after the included data has also been added to the store.

A better solution is to add the included data in the payload of load complete action of the “main” resource of the action (f.e. “Load course complete”). Since every resource reducer listens to all actions, we can catch the included data there, and if the type matches the type of that resource, we can add the data to the store without having to dispatch separate actions for this related data.

Expanding relationships

We avoid nesting related data in the store, as duplicating data in many places can easily lead to issues. Relationships are always stored as id’s only. The related data will automatically be upserted into the store with the included data mechanism (see above).

To easily retrieve this data, we can create Resource Expanders. These classes take an object and expand the relationships we need at that time. This allows us to f.e. get the names of all the languages of a course.

Technical docs about this can be found in the ResourceExpander interface. Two things to keep in mind:

  1. We don’t insert the data back into the parent class. When we expand a relationship, an observable is returned, not the actual data. This is similar to a select on the store. We will always need to subscribe to expanded relationships!
  2. As the parent objects usually come directly from the store, they are immutable. We cannot simply mutate them. So we always clone the original object and return the cloned version with the expandedRelationships added to it.

RequestState

To show progress spinners, load only fresh data (see: “Resolver”) and handle various errors with requests, it is useful to update the state of specific requests. In fact, lots of actions only update this RequestState, without even touching the actual state of the resource itself.

This typically creates a complicated state tree, and a messy reducer. Wouldn’t it be cool if we could abstract the entire RequestState, so we only have to worry about the actual data?

Solution

Simply add a RequestStateType to an action, and the RequestState reducer in the core module will automatically catch this type and store the correct RequestState for this action.

How does this work? There are 3 types of actions:

  • Load: when the initial request is dispatched. Examples: LoadOne, Add, Edit, …
  • Success: when this request was handled successfully. Examples: LoadOneSuccess, AddSuccess, EditSuccess, …
  • Fail: when the request failed. Examples: LoadOneFail, AddFail, EditFail, …

These 3 types will cover any type of action. No matter if we LoadOne or Add something, we want to display a progress spinner (or at least have the data in state to do so if we wish).

Identification

The identifier for a RequestState is a combination of its type and an optional entityId.

The main identifier is the type. The type is usually the ActionType related to the request. If we only pass a type, every new request of the same type (or ActionType) will share the same RequestState. This is recommended in most cases, as it will keep the state small and clean.

However, sometimes we really want to store a RequestState for each entity, f.e. if we delete records from an overview page, we want to show spinners on the records that are being deleted. To know which records are still being deleted, we really need a separate RequestState for each entity. The default resource actions take a “separateEntityRequestState” parameter (where applicable). Setting it to true will ensure the request state will be stored for every entity separately.

F.e. when adding many records in bulk, there is usually no id in the payload yet. If we still want to add many records at the same time and still want to separateEntityRequestState, this would be an issue, as it is based on the payload.id. However, we can pass an optional separateRequestStateId.

Example: A form with 30 SettingDefinitions and their values. We submit the form and want to track the errors for each SettingDefinition. We can pass f.e. “SettingDefinition 63” as the separateRequestStateId for SettingValues. In that case, we always want to prefix this id to avoid confusion: this is the SettingDefinition id, not the SettingValue id we would expect here.

Usage

Installation

@TODO a fresh installation as described below had never been attempted. Steps might be missing. Ideally, some sort of schematic should be created to automate installation.

  1. Install the package from npm

    npm install @krisvr/reduxresource
  2. Copy the reduxResource folder from the schematics/install folder to app folder of your application.

  3. Add the reducers to the imports section of the app.module:

    imports: [
        /**
         * Redux resource reducers
         */
        StoreModule.forFeature('reduxResource', reduxResourceReducer.reducers),
    
        /**
         * Application Redux resource reducers
         */
        StoreModule.forFeature('appReduxResource', appReduxResourceReducers),
    ] 
  4. Add the route serializer and environment providers to the app.module as well:

    providers: [
            {
                provide: 'environment',
                useValue: environment
            },
            {
                provide: RouterStateSerializer,
                useClass: RouteSerializer
            },
    ]
  5. Set up the root state in app/reducers/index.ts:

    /**
     * As mentioned, we treat each reducer like a table in a database. This means
     * our top level state interface is just a map of keys to inner state types.
     */
    export interface State {
        router: fromRouter.RouterReducerState<RouterState>;
        appReduxResource: AppReduxResourceState;
    }
    
    /**
     * Our state is composed of a map of action reducer functions.
     * These reducer functions are called with each dispatched action
     * and the current or initial state and return a new immutable state.
     */
    export const reducers: ActionReducerMap<State> = {
        router: fromRouter.routerReducer,
        appReduxResource: combineReducers(appReduxResourceReducers)
    };

Adding resource definitions

  1. Run the schematic:

    ng generate reduxresource:add-resource-definition --name=course --pluralName=courses
  2. Add the new Resource State and Reducer to app/reduxResource/appResourceReducer.ts:

    export interface AppReduxResourceState {
        courses: CoursesState;
    }
    
    export const appReduxResourceReducers: ActionReducerMap<AppReduxResourceState> = {
        courses: coursesReducers
    };
  3. Add the resource in app/reduxResource/appResourceSettings.ts:

    /**
     * A list of all api resource types with their Resource type name
     */
    public resourceTypes: {[key: string]: string} = {
        courses: 'Course'
    };
    
    /**
     * A list of Resource Factories that can be used to handle included data (relationships)
     * Ideally, every resource has a factory to map data of that type, and every factory should be listed below.
     */
    public factories: {[key: string]: any} = {
        Course: CourseFactory
    };
    
    /**
     * A list of all the resource selectors
     */
    public selectors: {[key: string]: any} = {
        Course: courseResourceSelectors
    };
  4. Register the effects in app.module.ts:

    EffectsModule.forFeature([
        CoursesEffects,
        NodesEffects
    ]),
  5. Add the model attributes and relationships, and adjust the factory to work with those attributes and relationships.

Custom actions & reducers

The included actions & reducers will handle most use cases, but if needed custom actions and reducers can be created.

There are two common scenarios:

  • Managing separate state that has no direct relation with the resource state.
  • Managing resource state through custom actions and reducers

Separate state

We can manage state that is completely separated from the resource state. Even though we don't use the resource actions, we can still take advantage of ReduxResource features such as automatic RequestState state.

  1. Write custom actions:

    export class LanguageActionTypes {
        static Resource = ResourceActionTypes('language');
    
        /*
        List specific actionTypes below f.e.
        static ToggleHidden = '[Courses overview] Toggle hidden records';
         */
        static SetContentLanguage = '[Login] Set content language'
    }
    
    export class LanguageActions {
        static Resource = ResourceActions<Language>(LanguageActionTypes.Resource);
    
        /**
         * Set the initial content language
         * @param {{ id: string | undefined }} payload
         */
        static SetContentLanguage = (payload: { id: string | undefined }) => ({
            type: LanguageActionTypes.SetContentLanguage,
            payload: payload
        })
    }
  2. Create a custom state interface and add it to the resource state (resource reducer):

    export interface SettingValuesState {
        resource: ResourceState<SettingValue>;
        custom: CustomState;
    }
  3. Write the reducer logic:

    export const reducers: ActionReducerMap<SettingValuesState> = {
        // The resource reducer
        resource: reducer,
    
        // Custom reducer
        custom: (
                state: CustomState = {
                    contentLanguages: []
                },
                action: PayloadAction
            ): CustomState => {
                switch (action.type) {
                    // Add a content language to the current context
                    case LanguageActionTypes.AddContentLanguage: {
                        return {
                            ...state,
                            contentLanguages: [
                                ...state.contentLanguages,
                                action.payload.id
                            ]
                        };
                    }
        
                    default: {
                        return state;
                    }
                }
            }
        )
    };

Resource state

We can also manage the resource state through custom actions and reducer logic:

  1. Write custom actions:

    export class SettingValueActionTypes {
        static Resource = ResourceActionTypes('settingValue');
    
        /*
        List specific actionTypes below f.e.
        static ToggleHidden = '[Courses overview] Toggle hidden records';
         */
        static LoadUserContentLanguage = '[Login] Load user content language';
    }
    
    /**
     * Load user content language
     */
    static LoadUserContentLanguage = () => ({
        type: SettingValueActionTypes.LoadUserContentLanguage,
        requestStateType: {
            type: RequestStateTypes.Load,
            startAction: SettingValueActionTypes.LoadUserContentLanguage
        }
    })
  2. Create the resource reducer and pass the actions through it, before (or after) adding your custom reducer logic. This example can be used and modified to your needs:

    export let {
        reducer,
        resource,
        initialState,
        entitiesAdapter,
        resourceFreshnessAdapter
    } = createResource<SettingValue>(
        SettingValuesConfig,
        SettingValueActionTypes,
        'SettingValue',
        SettingValueFactory
    );
    
    const resourceReducer = reducer;
    reducer = (
        resourceState: ResourceState<SettingValue> = initialState,
        action: PayloadAction
    ) => {
        // Start by running the action through the default resource reducer
        const state = resourceReducer(resourceState, action);
    
        // Handle custom actions
        switch (action.type) {
            case SettingValueActionTypes.LoadUserContentLanguageSuccess : {
                // TODO duplicated from LoadOne in the reduxResource reducer
                return {
                    ...state,
                    entities: entitiesAdapter.upsertOne(action.payload.entity, state.entities),
                    resourceFreshness: resourceFreshnessAdapter.upsertOne(
                        toResourceFreshness(action.payload.entity.id),
                        state.resourceFreshness
                    ),
                };
            }
    
            // Default
            default: {
                return state;
            }
        }
    };

TODO

  • Create schematic for installation
  • (Low-prio) Improve schematic for adding a resource definition
  • (Low-prio) Router state is not a part of the ReduxResource state, but goes in the root state. Which means we need to add a specific root reducer to store this.
  • (Low-prio) TranslatedString is part of the package, but doesn't have anything to do with ReduxResource itself. Could be considered a required component of f.e. Message etc... So "okay" for now.
0.0.69

3 years ago

0.0.70

3 years ago

0.0.71

3 years ago

0.0.72

3 years ago

0.0.67

3 years ago

0.0.68

3 years ago

0.0.65

3 years ago

0.0.66

3 years ago

0.0.64

3 years ago

0.0.62

3 years ago

0.0.63

3 years ago

0.0.61

3 years ago

0.0.59

3 years ago

0.0.60

3 years ago

0.0.58

3 years ago

0.0.57

3 years ago

0.0.55

3 years ago

0.0.56

3 years ago

0.0.54

4 years ago

0.0.53

4 years ago