TryGhost/Ghost

View on GitHub
ghost/admin/app/components/posts-list/selection-list.js

Summary

Maintainability
A
0 mins
Test Coverage
import {tracked} from '@glimmer/tracking';

export default class SelectionList {
    @tracked selectedIds = new Set();
    @tracked inverted = false;
    @tracked lastSelectedId = null;
    @tracked lastShiftSelectionGroup = new Set();

    enabled = true;

    /**
     * The infinity model containing the list of posts.
     * @type {Object}
     * @property {InfinityModel} scheduledInfinityModel - The infinity model for scheduled posts.
     * @property {InfinityModel} draftInfinityModel - The infinity model for draft posts.
     * @property {InfinityModel} publishedAndSentInfinityModel - The infinity model for published and sent posts.
     */
    infinityModel;

    #frozen = false;

    /**
     * When doing right click on an item, we temporarily select it, but want to clear it as soon as we close the context menu.
     */
    #clearOnNextUnfreeze = false;

    constructor(infinityModel) {
        this.infinityModel = infinityModel ?? {
            draftInfinityModel: {
                content: []
            }
        };
    }

    freeze() {
        this.#frozen = true;
    }

    unfreeze() {
        this.#frozen = false;
        if (this.#clearOnNextUnfreeze) {
            this.clearSelection();
            this.#clearOnNextUnfreeze = false;
        }
    }

    clearOnNextUnfreeze() {
        this.#clearOnNextUnfreeze = true;
    }

    /**
     * Returns an NQL filter for all items, not the selection
     */
    get allFilter() {
        const models = this.infinityModel;
        // grab filter from the first key in the infinityModel object (they should all be identical)
        for (const key in models) {
            return models[key].extraParams?.allFilter ?? '';
        }
        return '';
    }

    /**
     * Returns an NQL filter for the current selection
     */
    get filter() {
        if (this.inverted) {
            if (this.allFilter) {
                if (this.selectedIds.size === 0) {
                    return this.allFilter;
                }
                return `(${this.allFilter})+id:-['${[...this.selectedIds].join('\',\'')}']`;
            }
            if (this.selectedIds.size === 0) {
                // Select all
                return '';
            }
            return `id:-['${[...this.selectedIds].join('\',\'')}']`;
        }
        if (this.selectedIds.size === 0) {
            // Select nothing
            return 'id:nothing';
        }
        // Only based on the ids
        return `id:['${[...this.selectedIds].join('\',\'')}']`;
    }

    /**
     * Create an empty copy
     */
    cloneEmpty() {
        return new SelectionList(this.infinityModel);
    }

    /**
     * Return a list of models that are already loaded in memory.
     * Keep in mind that when using CMD + A, we don't have all items in memory!
     */
    get availableModels() {
        const models = this.infinityModel;
        const arr = [];
        for (const key in models) {
            for (const item of models[key].content) {
                if (this.isSelected(item.id)) {
                    arr.push(item);
                }
            }
        }
        return arr;
    }

    get first() {
        return this.availableModels[0];
    }

    get isSingle() {
        return this.selectedIds.size === 1 && !this.inverted;
    }

    get count() {
        if (!this.inverted) {
            return this.selectedIds.size;
        }
        
        const models = this.infinityModel;
        let total;
        for (const key in models) {
            total += models[key].meta?.pagination?.total;
        }   
        return Math.max((total ?? 0) - this.selectedIds.size, 1);
    }

    isSelected(id) {
        if (this.inverted) {
            return !this.selectedIds.has(id);
        }
        return this.selectedIds.has(id);
    }

    toggleItem(id) {
        if (this.#frozen) {
            return;
        }
        this.lastShiftSelectionGroup = new Set();

        if (this.selectedIds.has(id)) {
            this.selectedIds.delete(id);

            if (!this.inverted) {
                if (this.lastSelectedId === id) {
                    this.lastSelectedId = null;
                }
            } else {
                // Shift behaviour in inverted mode needs a review
                this.lastSelectedId = id;
            }
        } else {
            this.selectedIds.add(id);

            if (!this.inverted) {
                this.lastSelectedId = id;
            } else {
                // Shift behaviour in inverted mode needs a review
                this.lastSelectedId = id;
            }
        }

        // Force update
        // eslint-disable-next-line no-self-assign
        this.selectedIds = this.selectedIds;
    }

    clearUnavailableItems() {
        const newSelection = new Set();
        const models = this.infinityModel;
        for (const key in models) {
            for (const item of models[key].content) {
                if (this.selectedIds.has(item.id)) {
                    newSelection.add(item.id);
                }
            }
        }
        this.selectedIds = newSelection;
    }

    /**
     * Select all items between the last selection or the first one if none
     */
    shiftItem(id) {
        if (this.#frozen) {
            return;
        }
        if (this.lastSelectedId === null) {
            // Do a normal toggle
            this.toggleItem(id);
            return;
        }

        // Unselect last selected items
        for (const item of this.lastShiftSelectionGroup) {
            if (this.inverted) {
                this.selectedIds.add(item);
            } else {
                this.selectedIds.delete(item);
            }
        }
        this.lastShiftSelectionGroup = new Set();

        let running = false;
        const modelOrder = ['scheduledInfinityModel', 'draftInfinityModel', 'publishedAndSentInfinityModel'];
        const models = this.infinityModel;

        for (const modelKey of modelOrder) {
            const model = models[modelKey];
            if (!model?.content || model.content.length === 0) {
                continue;
            }

            for (const item of model.content) {
                if (item.id === this.lastSelectedId || item.id === id) {
                    if (!running) {
                        running = true;
                        if (item.id === this.lastSelectedId) {
                            continue;
                        }
                    } else {
                        this.lastShiftSelectionGroup.add(item.id);
                        this.inverted ? this.selectedIds.delete(item.id) : this.selectedIds.add(item.id);
                        running = false;
                        break;
                    }
                }

                if (running) {
                    this.lastShiftSelectionGroup.add(item.id);
                    this.inverted ? this.selectedIds.delete(item.id) : this.selectedIds.add(item.id);
                }
            }
        }

        // Force update
        // eslint-disable-next-line no-self-assign
        this.selectedIds = this.selectedIds;
    }

    selectAll() {
        if (this.#frozen) {
            return;
        }
        this.selectedIds = new Set();
        this.inverted = !this.inverted;
        this.lastSelectedId = null;
    }

    clearSelection(options = {}) {
        if (this.#frozen && !options.force) {
            return;
        }
        this.selectedIds = new Set();
        this.inverted = false;
        this.lastSelectedId = null;
    }
}