Source: public/src/store/index.js

/** @module store */

import { observer, inject, Provider } from 'mobx-react';
import AppTree from '../AppTree';
import { rmlOpts, i18n, ajax, fetchTree as utilFetchTree, applyNodeDefaults, ICON_OBJ_FOLDER_CLOSED,
    ICON_OBJ_FOLDER_OPEN, ICON_OBJ_FOLDER_ROOT, ICON_OBJ_FOLDER_COLLECTION, ICON_OBJ_FOLDER_GALLERY,
    humanFileSize, secondsFormat } from '../util';
import { Icon } from 'react-aiot';
import { types, flow, resolvePath, onPatch } from "mobx-state-tree";
import TreeNode from './TreeNode';
import Upload from './Upload';

/**
 * The main Mobx State Tree store for the RML application. It holds a static tree and
 * the fetched tree from the server. The properties are read-only.
 * 
 * @class Store
 * @property {int} [rootId=rmlOpts.rootId] The root folder id
 * @property {module:store/TreeNode~TreeNode[]} staticTree The static tree
 * @property {module:store/TreeNode~TreeNode[]} [tree] The tree
 * @property {object} [refs] Refs to all available tree nodes
 * @property {string|int} [selectedId=0] The selected id
 * @property {mixed[]} [foldersNeedsRefresh] Node ids which needs to be refreshed when they gets queried
 * @property {module:store/Upload~Upload[]} [uploading] The upload queue
 * @property {int} [uploadTotalLoaded=0] The upload total loaded
 * @property {int} [uploadTotalSize=0] The upload total size
 * @property {object} [sortables] Available sortables for the order menu
 * @property {int} [uploadTotalBytesPerSec=0] The uploader bytes per second
 * @property {module:store/TreeNode~TreeNode} [selected] The selected tree node
 * @property {module:store/Upload~Upload} [currentUpload] The current upload file
 * @property {string} [uploadTotalRemainTime] The current upload remaining time in human readable form
 * @property {string} [readableUploadTotalLoaded] The uploader total loaded in human readable form
 * @property {string} [readableUploadTotalSize] The uploader total size in human readable form
 * @property {string} [readableUploadTotalBytesPerSec] The uploader bytes per second in human readable form
 */ 
const Store = types.model('RMLStore', {
    rootId: +rmlOpts.rootId,
    staticTree: types.array(TreeNode),
    tree: types.optional(types.array(TreeNode), []),
    refs: types.optional(types.map(types.reference(TreeNode)), {}),
    selectedId: types.optional(types.union(types.string, types.number), 0), // Do not fill manually, it is filled in afterCreated through onPatch
    foldersNeedsRefresh: types.optional(types.array(types.union(types.string, types.number)), []),
    uploading: types.optional(types.array(Upload), []),
    uploadTotalLoaded: types.optional(types.number, 0),
    uploadTotalSize: types.optional(types.number, 0),
    sortables: types.optional(types.frozen),
    uploadTotalBytesPerSec: types.optional(types.number, 0)
}).views(self => ({
    /**
     * Get tree item by id.
     * 
     * @param {string|int} id
     * @param {boolean} [exlucdeStatic=true]
     * @returns {module:store/TreeNode~TreeNode} Tree node
     * @memberof module:store~Store
     * @instance
     */
    getTreeItemById(id, excludeStatic = true) {
        const useId = parseInt(id, 10),
            ref = self.refs.get(isNaN(useId) ? id : useId);
        return excludeStatic && ref && self.staticTree.indexOf(ref) > -1 ? undefined : ref;
    },
    
    get selected() {
        return self.getTreeItemById(self.selectedId, false);
    },
    
    get currentUpload() {
        return self.uploading.length ? self.uploading[0] : undefined;
    },
    
    get uploadTotalRemainTime() {
        if (self.uploadTotalBytesPerSec > 0) {
            const remainTime = Math.floor((self.uploadTotalSize - self.uploadTotalLoaded) / self.uploadTotalBytesPerSec);
            return secondsFormat(remainTime);
        }else{
            return '00:00:00';
        }
    },
    
    get readableUploadTotalLoaded() {
        return humanFileSize(self.uploadTotalLoaded);
    },
    
    get readableUploadTotalSize() {
        return humanFileSize(self.uploadTotalSize);
    },
    
    get readableUploadTotalBytesPerSec() {
        return humanFileSize(self.uploadTotalBytesPerSec);
    }
})).actions(self => ({
    /**
     * The model is created so watch for specific properties. For example set
     * the selected property.
     * 
     * @memberof module:store~Store
     * @private
     * @instance
     */
    afterCreate() {
        onPatch(self, ({ op, path, value }) => {
            // A new selected item is setted
            if ((path.startsWith('/tree/') || path.startsWith('/staticTree/')) && path.endsWith('/selected') && value === true) {
                const currentSelected = self.selected, obj = resolvePath(self, path.slice(0, path.length - 9));
                currentSelected && currentSelected.id !== obj.id && currentSelected.setter(node => { node.selected = false; });
                self._setSelectedIdFromPath(obj);
            }
        });
        
        // Initially load sortables
        !self.sortables && self.fetchSortables();
    },
    
    _setSelectedIdFromPath(obj) {
        self.selectedId = obj.id;
    },
    
    /**
     * A new tree node is created (static or normal tree).
     * 
     * @memberof module:store~Store
     * @instance
     * @private
     */
    _afterAttachTreeNode(instance) {
        self.refs.set(instance.id, instance);
    },
    
    /**
     * An instance is destroyed.
     * 
     * @private
     */
    _beforeDestroyTreeNode(instance) {
        self.refs.delete(instance.id);
    },
    
    /**
     * Update this node attributes.
     * 
     * @param {function} callback The callback with one argument (node draft)
     * @memberof module:store~Store
     * @instance
     */
    setter(callback) {
        callback(self);
    },
    
    /**
     * Set the tree.
     * 
     * @param {object} tree The object representing a tree
     * @param {boolean} [isStatic=false]
     * @memberof module:store~Store
     * @instance
     */
    setTree(tree, isStatic = false) {
        if (isStatic) {
            self.staticTree = tree;
        }else{
            self.tree = tree;
        }
    },
    
    /**
     * Set upload total stats.
     * 
     * @memberof module:store~Store
     * @instance
     */
    setUploadTotal({ loaded, size, bytesPerSec }) {
        self.uploadTotalLoaded = loaded;
        self.uploadTotalSize = size;
        self.uploadTotalBytesPerSec = bytesPerSec;
    },
    
    /**
     * Add an uploading file.
     * 
     * @param {object} object The object to push
     * @returns {object} The upload instance
     * @memberof module:store~Store
     * @instance
     */
    addUploading(upload) {
        self.uploading.push(upload);
        return self.uploading[self.uploading.length - 1];
    },
    
    /**
     * Register a folder that it needs refresh.
     * 
     * @memberof module:store~Store
     * @instance
     */
    addFoldersNeedsRefresh(id) {
        self.foldersNeedsRefresh.indexOf(id) === -1 && self.foldersNeedsRefresh.push(id);
    },
    
    /**
     * Register a folder that it needs refresh.
     * 
     * @memberof module:store~Store
     * @instance
     */
    removeFoldersNeedsRefresh(id) {
        const idx = self.foldersNeedsRefresh.indexOf(id);
        idx > -1 && self.foldersNeedsRefresh.splice(idx, 1);
    },
    
    /**
     * Remove an uploading file from queue.
     * 
     * @param {string} cid The cid
     * @returns {object} A copy of the original object
     * @memberof module:store~Store
     * @instance
     */
    removeUploading(cid) {
        const found = self.uploading.filter(u => u.cid === cid),
            result = !!found.length && found[0].toJSON();
        found.length && self.uploading.splice(self.uploading.indexOf(found[0]), 1);
        return result;
    },
    
    /**
     * Handle sort mechanism.
     * 
     * @returns {boolean}
     * @throws Error
     * @memberof module:store~Store
     * @instance
     */
    handleSort: flow(function*({ id, oldIndex, newIndex, parentFromId, parentToId, nextId, request = true }) {
        const { tree, rootId } = self;
        
        // Find parent trees with children
        let treeItem;
        if (parentFromId === rootId) {
            treeItem = tree[oldIndex].toJSON();
            tree.splice(oldIndex, 1);
        }else{
            self.getTreeItemById(parentFromId).setter(node => {
                treeItem = node.childNodes[oldIndex].toJSON();
                node.childNodes.splice(oldIndex, 1);
            }, true);
        }
        
        // Find destination tree
        if (parentToId === rootId) {
            tree.splice(newIndex, 0, treeItem);
        }else{
            self.getTreeItemById(parentToId).setter(node => {
                node.childNodes.splice(newIndex, 0, treeItem);
            }, true);
        }
        
        if (!request) {
            return true;
        }
        
        // Request
        try {
            yield ajax('hierarchy/' + id, {
                method: 'PUT',
                data: {
                    parent: parentToId,
                    nextId: nextId === 0 ? false : nextId
                }
            });
            return true;
        }catch (e) {
            yield store.handleSort({
                id: self.rootId,
                oldIndex: newIndex,
                newIndex: oldIndex,
                parentFromId: parentToId,
                parentToId: parentFromId,
                request: false
            });
            throw e;
        }
    }),
    
    /**
     * Fetch the folder tree.
     * 
     * @returns {object[]} Tree
     * @memberof module:store~Store
     * @instance
     * @async
     */
    fetchTree: flow(function*(setSelectedId) {
        const result = { tree, cntRoot, cntAll, slugs } = yield utilFetchTree();
        self.setTree(tree);
        
        typeof setSelectedId !== 'undefined' && self.getTreeItemById(setSelectedId, false).setter(node => (node.selected = true));
        self.getTreeItemById('all', false).setter(node => (node.count = cntAll));
        self.getTreeItemById(self.rootId, false).setter(node => (node.count = cntRoot));
        return result;
    }),
    
    /**
     * Update the folder count. If you pass no argument the folder count is
     * requested from server.
     * 
     * @param {object} counts Key value map of folder and count
     * @returns {object<string|int,int>} Count map
     * @memberof module:store~Store
     * @instance
     * @async
     */
    fetchCounts: flow(function*(counts) {
        if (counts) {
            Object.keys(counts).forEach(k => {
                const ref = self.getTreeItemById(k, false);
                ref && (ref.count = counts[k]);
            });
            return counts;
        }
        return yield self.fetchCounts(yield ajax('folders/content/counts'));
    }),
    
    /**
     * Fetch sortables.
     * 
     * @memberof module:store~Store
     * @instance
     * @async
     */
    fetchSortables: flow(function*() {
        self.sortables = yield ajax('folders/content/sortables');
    }),
    
    /**
     * Create a new tree node.
     * 
     * @param {string} name The name of the new folder
     * @param {object} obj The object representing the folder
     * @param {string|int} obj.parent 
     * @param {int} obj.typeInt 
     * @param {function} [beforeAttach] Callback executed before attaching the new object to the tree
     * @returns {object} The tree node (no mobx model)
     * @memberof module:store~Store
     * @instance
     * @async
     */
    persist: flow(function*(name, { parent, typeInt }, beforeAttach) {
        const newObj = applyNodeDefaults([ yield ajax('folders', {
            method: 'POST',
            data: {
                name,
                parent,
                type: typeInt
            }
        }) ])[0];
        
        // Add to tree
        beforeAttach && beforeAttach(newObj);
        if (parent === self.rootId) {
            self.tree.push(newObj);
        }else{
            self.getTreeItemById(parent).setter(node => {
                node.childNodes.push(newObj); 
            }, true);
        }
        return newObj;
    })
}));

/**
 * Main store instance.
 */
const store = Store.create({
    staticTree: [{
        id: 'all',
        title: <span>{ i18n('allPosts') }</span>,
        icon: <Icon type="copy" />,
        count: rmlOpts.allPostCnt
    }, {
        id: +rmlOpts.rootId,
        title: i18n('unorganized'),
        icon: ICON_OBJ_FOLDER_ROOT,
        count: 0,
        properties: {
            type: 4
        }
    }]
});

/**
 * A single instance of store.
 */
export default store;

/**
 * An AppTree implementation with store provided. This means you have no longer
 * implement the Provider of mobx here.
 * 
 * @returns {React.Element}
 */
export const StoredAppTree = ({ children, ...rest }) => (<Provider store={ store }>
    <AppTree {...rest}>{ children }</AppTree>
</Provider>);

/**
 * Import general store to ReactJS component.
 */
export function injectAndObserve(fn, store = 'store') {
    return inject(store)(observer(fn));
}

export { TreeNode, Upload };