Organizar módulos genéricos en Vuex

Cómo organizamos las tiendas Vuex y superamos la función de copiar y pegar



Vuex es la biblioteca oficial de administración del estado de aplicaciones diseñada específicamente para el marco Vue.js.



Vuex implementa un patrón de administración de estado que sirve como un almacén de datos centralizado para todos los componentes de una aplicación.



A medida que la aplicación crece, este almacenamiento crece y los datos de la aplicación se colocan en un objeto grande.



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