Organizing generic modules in Vuex

How we organized Vuex stores and beat copy-paste



Vuex is the official application state management library designed specifically for the Vue.js framework.



Vuex implements a state management pattern that serves as a centralized data store for all components of an application.



As the application grows, this storage grows and the application data is placed in one large object.



Vuex . , , , .



CloudBlue Connect , , , :



  1. , ;
  2. Vuex, ;
  3. - .


, . , .



. , , .



Vuex, ().



Vuex



1.



BaseRepository REST API. CRUD-, , .



, , API.

, (: /v1/users).



:

query — .



class BaseRepository {
    constructor(entity, version = 'v1') {
        this.entity = entity;
        this.version = version;
    }

    get endpoint() {
        return `/${this.version}/${this.entity}`;
    }

    async query({
        method = 'GET',
        nestedEndpoint = '',
        urlParameters = {},
        queryParameters = {},
        data = undefined,
        headers = {},
    }) {
        const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

        const result = await axios({
            method,
            url,
            headers,
            data,
            params: queryParameters,
        });

        return result;
    }

    ...
}


getTotal — .

Content-Range, : Content-Range: <unit> <range-start>-<range-end>/<size>.



// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

...

async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({
        queryParameters: { ...queryParameters, limit: 1 },
        urlParameters,
    });

    if (!headers['Content-Range']) {
        throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
}


:



  • listAll — ;
  • list — ( );
  • get — ;
  • create — ;
  • update — ;
  • delete — .


: .



listAll, . getTotal, , . chunkSize .



, .



BaseRepository.js
import axios from 'axios';

// parameterize :: replace substring in string by template
// parameterize :: Object -> String -> String
// parameterize :: {userId: '123'} -> '/users/:userId/activate' -> '/users/123/activate'
const parameterize = (url, urlParameters) => Object.entries(urlParameters)
  .reduce(
    (a, [key, value]) => a.replace(`:${key}`, value), 
    url,
  );

// responsesToCollection :: Array -> Array
// responsesToCollection :: [{data: [1, 2]}, {data: [3, 4]}] -> [1, 2, 3, 4]
const responsesToCollection = responses => responses.reduce((a, v) => a.concat(v.data), []);

// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

// getCollectionAndTotal :: Object -> Object
// getCollectionAndTotal :: { data, headers } -> { collection, total }
const getCollectionAndTotal = ({ data, headers }) => ({
  collection: data,
  total: headers['Content-Range'] && getContentRangeSize(headers['Content-Range']),
})

export default class BaseRepository {
  constructor(entity, version = 'v1') {
    this.entity = entity;
    this.version = version;
  }

  get endpoint() {
    return `/${this.version}/${this.entity}`;
  }

  async query({
    method = 'GET',
    nestedEndpoint = '',
    urlParameters = {},
    queryParameters = {},
    data = undefined,
    headers = {},
  }) {
    const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

    const result = await axios({
      method,
      url,
      headers,
      data,
      params: queryParameters,
    });

    return result;
  }

  async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({ 
      queryParameters: { ...queryParameters, limit: 1 },
      urlParameters,
    });

    if (!headers['Content-Range']) {
      throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
  }

  async list(queryParameters, urlParameters) {
    const result = await this.query({ urlParameters, queryParameters });

    return { 
      ...getCollectionAndTotal(result),
      params: queryParameters,
    };
  }

  async listAll(queryParameters = {}, urlParameters, chunkSize = 100) {
    const params = { 
      ...queryParameters,
      offset: 0,
      limit: chunkSize,
    };

    const requests = [];
    const total = await this.getTotal(urlParameters, queryParameters);

    while (params.offset < total) {
      requests.push(
        this.query({ 
          urlParameters, 
          queryParameters: params,
        }),
      );

      params.offset += chunkSize;
    }

    const result = await Promise.all(requests);

    return {
      total,
      params: {
        ...queryParameters,
        offset: 0,
        limit: total,
      },
      collection: responsesToCollection(result),
    };
  }

  async create(requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'POST',
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async get(id = '', urlParameters, queryParameters = {}) {
    const { data } = await this.query({
      method: 'GET',
      nestedEndpoint: `/${id}`,
      urlParameters,
      queryParameters,
    });

    return data;
  }

  async update(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'PUT',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async delete(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'DELETE',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }
}


API, .

, users :



const usersRepository = new BaseRepository('users');
const win0err = await usersRepository.get('USER-007');


, ?

, , POST- /v1/users/:id/activate.

, :



class UsersRepository extends BaseRepository {
    constructor() {
        super('users');
    }

    activate(id) {
        // POST /v1/users/:id/activate
        return this.query({
            nestedEndpoint: '/:id/activate',
            method: 'POST',
            urlParameters: { id },
        });
    }
}


API :



const usersRepository = new UsersRepository();
await usersRepository.activate('USER-007');
await usersRepository.listAll();


2.



, , .

. , , .





, .

value , :



import {
    is,
    clone,
} from 'ramda';

const mutations = {
    replace: (state, { obj, value }) => {
        const data = clone(state[obj]);

        state[obj] = is(Function, value) ? value(data) : value;
    },
}




, - , .

, .



, :



  • collection — ;
  • current — ;
  • total — .




, , : get, list, listAll, create, update delete. , .



, , .





, registerModule: store.registerModule(name, module);.



, , . , , .



StoreFactory.js
import {
  clone,
  is,
  mergeDeepRight,
} from 'ramda';

const keyBy = (pk, collection) => {
  const keyedCollection = {};
  collection.forEach(
      item => keyedCollection[item[pk]] = item,
  );

  return keyedCollection;
}

const replaceState = (state, { obj, value }) => {
  const data = clone(state[obj]);

  state[obj] = is(Function, value) ? value(data) : value;
};

const updateItemInCollection = (id, item) => collection => {
  collection[id] = item;

  return collection
};

const removeItemFromCollection = id => collection => {
  delete collection[id];

  return collection
};

const inc = v => ++v;
const dec = v => --v;

export const createStore = (repository, primaryKey = 'id') => ({
  namespaced: true,

  state: {
    collection: {},
    currentId: '',

    total: 0,
  },

  getters: {
    collection: ({ collection }) => Object.values(collection),
    total: ({ total }) => total,
    current: ({ collection, currentId }) => collection[currentId],
  },

  mutations: {
    replace: replaceState,
  },

  actions: {
    async list({ commit }, attrs = {}) {
      const { queryParameters = {}, urlParameters = {} } = attrs;

      const result = await repository.list(queryParameters, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async listAll({ commit }, attrs = {}) {
      const {
        queryParameters = {},
        urlParameters = {},
        chunkSize = 100,
      } = attrs;

      const result = await repository.listAll(queryParameters, urlParameters, chunkSize)

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async get({ commit, getters }, attrs = {}) {
      const { urlParameters = {}, queryParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      try {
        const item = await repository.get(
          id,
          urlParameters,
          queryParameters,
        );

        commit({
          obj: 'collection',
          type: 'replace',
          value: updateItemInCollection(id, item),
        });

        commit({
          obj: 'currentId',
          type: 'replace',
          value: id,
        });
      } catch (e) {
        commit({
          obj: 'currentId',
          type: 'replace',
          value: '',
        });

        throw e;
      }

      return getters.current;
    },

    async create({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;

      const createdItem = await repository.create(data, urlParameters);
      const id = createdItem[primaryKey];

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, createdItem),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: inc,
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async update({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      const item = await repository.update(id, data, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, item),
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async delete({ commit }, attrs = {}) {
      const { urlParameters = {}, data } = attrs;
      const id = urlParameters[primaryKey];

      await repository.delete(id, urlParameters, data);

      commit({
        obj: 'collection',
        type: 'replace',
        value: removeItemFromCollection(id),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: dec,
      });
    },
  },
});

const StoreFactory = (repository, extension = {}) => {
  const genericStore = createStore(
    repository, 
    extension.primaryKey || 'id',
  );

  ['state', 'getters', 'actions', 'mutations'].forEach(
    part => {
      genericStore[part] = mergeDeepRight(
        genericStore[part],
        extension[part] || {},
      );
    }
  )

  return genericStore;
};

export default StoreFactory;




:



const usersRepository = new UsersRepository();
const usersModule = StoreFactory(usersRepository);


, , .

:



import { assoc } from 'ramda';

const usersRepository = new UsersRepository();
const usersModule = StoreFactory(
    usersRepository,
    {
        actions: {
            async activate({ commit }, { urlParameters }) {
                const { id } = urlParameters;
                const item = await usersRepository.activate(id);

                commit({
                    obj: 'collection',
                    type: 'replace',
                    value: assoc(id, item),
                });
            }
        }
    },
);


3.



, , , , :



ResourceFactory.js
import BaseRepository from './BaseRepository';
import StoreFactory from './StoreFactory';

const createRepository = (endpoint, repositoryExtension = {}) => {
  const repository = new BaseRepository(endpoint, 'v1');

  return Object.assign(repository, repositoryExtension);
}

const ResourceFactory = (
  store,
  {
    name,
    endpoint,
    repositoryExtension = {},
    storeExtension = () => ({}),
  },
) => {
    const repository = createRepository(endpoint, repositoryExtension);
    const module = StoreFactory(repository, storeExtension(repository));

    store.registerModule(name, module);
}

export default ResourceFactory;




. , ( ) :



const store = Vuex.Store();

ResourceFactory(
    store,
    {
        name: 'users',
        endpoint: 'users',
        repositoryExtension: {
            activate(id) {
                return this.query({
                    nestedEndpoint: '/:id/activate',
                    method: 'POST',
                    urlParameters: { id },
                });
            },
        },
        storeExtension: (repository) => ({
            actions: {
                async activate({ commit }, { urlParameters }) {
                    const { id } = urlParameters;
                    const item = await repository.activate(id);

                    commit({
                        obj: 'collection',
                        type: 'replace',
                        value: assoc(id, item),
                    });
                }
            }
        }),
    },
);


, : , :



{
    computed: {
        ...mapGetters('users', {
            users: 'collection',
            totalUsers: 'total',
            currentUser: 'current',
        }),

        ...mapGetters('groups', {
            users: 'collection',
        }),

        ...
    },

    methods: {
        ...mapActions('users', {
            getUsers: 'list',
            deleteUser: 'delete',
            updateUser: 'update',
            activateUser: 'activate',
        }),

        ...mapActions('groups', {
            getAllUsers: 'listAll',
        }),

        ...

        async someMethod() {
            await this.activateUser({ urlParameters: { id: 'USER-007' } });
            ...
        }
    },
}


- , .

, , .



:



ResourceFactory(
    store,
    {
        name: 'userOrders',
        endpoint: 'users/:userId/orders',
    },
);


:



{
    ...

    methods: {
        ...mapActions('userOrders', {
            getOrder: 'get',
        }),

        async someMethod() {
            const order = await this.getOrder({ 
                urlParameters: { 
                    userId: 'USER-007',
                    id: 'ORDER-001',
                } 
            });

            console.log(order);
        }
    }
}




. , — . — , . — (mocks), , .

, — , .





, DRY, . , , API . , Content-Range, .



() , , , , -. , , .



, . , .




All Articles