Q-tree within Q-menu



  • Although I don’t know how you could solve your case I did something similar but I used popup proxy. It takes some hacking to make it work. Like I had to have 2 tree’s to be able to reference it and use its functions. I also change if the popup is persistent or not.

    https://quasar.dev/vue-components/popup-proxy#QPopupProxy-API

    I leave the code here in case it helps but I am sure someone will come up a better way of doing this.

    <template>
        <div class="relative-position v_tree_select" @keydown="on_field_keydown" @click="on_field_click">
    
            <q-field
                ref="field"
                stack-label
                tabindex="0"
                v-model="filter"
                :disable="_disable"
                :hint="hint"
                :label="label"
                :loading="loading"
                @focus="on_focus"
                @input="on_input"
            >
                <template v-slot:append>
                    <q-icon name="arrow_drop_down" />
                </template>
    
                <!-- :borderless="borderless" -->
                <template v-slot:control>
                    <div class="v_tree_select_field_wrap">
                        <template v-if="_tree.length !== 0">
                            <template v-if="multi_select && ticked_nodes.length > 0 ">
                                <q-chip color="primary" text-color="white" dense v-for="node in ticked_nodes" :key="node[tree_key]">
                                    {{ node[tree_label_key] }}
                                    <svg
                                        v-if="clearable"
                                        @click.stop="remove_chip(node[tree_key])"
                                        aria-hidden="true"
                                        role="presentation"
                                        focusable="false"
                                        viewBox="0 0 24 24"
                                        class="q-chip__icon q-chip__icon--remove cursor-pointer q-icon notranslate"
                                        tabindex="-1">
                                        <path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z">
                                        </path>
                                    </svg>
                                </q-chip>
                            </template>
    
                            <template v-if="!multi_select && selected_node">
                                <q-chip color="primary" text-color="white" dense>
                                    {{ selected_node[tree_label_key] }}
                                    <svg
                                        v-if="clearable"
                                        @click.stop="remove_chip()"
                                        aria-hidden="true"
                                        role="presentation"
                                        focusable="false"
                                        viewBox="0 0 24 24"
                                        class="q-chip__icon q-chip__icon--remove cursor-pointer q-icon notranslate"
                                        tabindex="-1">
                                        <path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z">
                                        </path>
                                    </svg>
                                </q-chip>
                            </template>
                        </template>
                        <template v-else>
                            <div class="text-grey">No nodes available</div>
                        </template>
                    </div>
                </template>
    
                <!-- HIDDEN TREE SO WE CAN GET THE REFRENCE TO IT AND USE ITS METHODS-->
                <q-tree
                    style="display:none"
                    ref="hidden_tree"
                    :nodes="_tree"
                    :node-key="tree_key"
                    :label-key="tree_label_key"
                    :default-expand-all="default_expand_all"
                    :selected="selected"
                    :ticked="ticked"
                    @update:selected="node_selected"
                    @update:ticked="node_ticked"
                    :key="hidden_key"
                    :tickStrategy="_tick_strategy"
                >
                </q-tree>
    
                <q-popup-proxy
                    :breakpoint="600"
                    fit
                    ref="popup"
                    full-width
                    content-class="v_tree_select_dialog"
                    v-model="dialog"
                    :persistent="persistent"
                    no-parent-event
                >
                    <!-- no-parent-event -->
                    <!-- maximized -->
                    <div class="column v_tree_select_popup_wrap">
                        <!-- <q-input bottom-slots v-model="filter" filled :dense="dense"  placeholder="Placeholder"  >
                            <template v-slot:prepend>
                                <q-icon name="filter_list"></q-icon>
                            </template>
                        </q-input> -->
                        <q-input v-show="searchable" ref="filter" v-model="filter" class="v_tree_select_filter_input" clearable placeholder="Filter" filled @keydown="on_filter_keydown">
                            <template v-slot:prepend>
                                <q-icon name="filter_list" />
                            </template>
                        </q-input>
                        <div class="col scroll" @keydown="on_keydown"
                        tabindex="0">
                            <q-tree
                                ref="tree"
                                class="q-ma-sm col"
                                :default-expand-all="default_expand_all"
                                :filter="filter"
                                :key="key"
                                :label-key="tree_label_key"
                                :nodes="_tree"
                                :node-key="tree_key"
                                :selected="selected"
                                :ticked="ticked"
                                :tickStrategy="_tick_strategy"
                                @update:selected="node_selected"
                                @update:ticked="node_ticked"
    
                            >
                            </q-tree>
                        </div>
                        <div class="q-pa-md col-auto v_tree_select_dialog_close">
                            <q-btn label="Close" unelevated class="full-width" color="primary" @click="hide_dialog"  />
                        </div>
    
                        <!-- list_loading -->
                        <q-inner-loading :showing="list_loading" >
                            <q-spinner-gears size="50px" color="primary" />
                        </q-inner-loading>
                    </div>
                </q-popup-proxy>
            </q-field>
    
        </div>
    </template>
    
    <script>
    
    export default {
        props: {
            after_change: {
                type: Function
            },
            borderless: {
                type: Boolean,
                default: true
            },
            clearable: {
                type: Boolean,
                default: true
            },
            close_after_select: {
                type: Boolean,
                default: true
            },
            confirm_action: {
                type: String
            },
            default_expand_all: {
                type: Boolean,
                default: true
            },
            disable: {
                type: Boolean,
                default: false
            },
            field: {
                type: String
            },
            hint: {
                type: String
            },
            label: {
                type: String,
                default: ''
            },
            load_trigger: {
                type: [String, Number, Object]
            },
            multi_select: {
                type: Boolean,
                default: false
            },
            section_open: {
                type: Boolean
            },
            searchable: {
                type: Boolean,
                default: true
            },
            sync_data: {
                type: [String, Object, Array]
            },
            sync_selected_key: {
                type: String,
                default: 'relations'
            },
            sync_action: {
                type: String
            },
    
            tick_strategy: {
                type: String,
                default: 'strict'
            },
    
            // tree
            tree: {
                type: Array
            },
            tree_key: {
                type: String,
                required: true
            },
            tree_label_key: {
                type: String,
                required: true
            },
            tree_field: {
                type: String
            },
            tree_action: {
                type: String
            },
            tree_getter_data: {
                type: [String, Number, Array, Object]
            },
            tree_getter: {
                type: String,
                default: ''
            },
    
            // value
            value: {
                type: [String, Array, Number]
            },
            value_action: {
                type: String
            },
            value_action_data: {
                type: [String, Object, Array]
            },
            value_getter: {
                type: String
            }
    
        },
        data: function () {
            return {
                dialog: false,
                expanded: [],
                filter: '',
                hidden_key: 1,
                key: 1,
                list_loading: false,
                loading: false,
                model: null,
                persistent: false,
                selected_node: null,
                ticked_nodes: []
            };
        },
        computed: {
            _disable () {
                if (this._tree.length === 0) {
                    return true;
                } else if (this.loading) {
                    return true;
                } else {
                    return this.disable;
                }
            },
            selected () {
                if (this.multi_select) {
                    return '';
                }
                return this._value;
            },
            _tick_strategy () {
                if (this.multi_select) {
                    return this.tick_strategy;
                } else {
                    return undefined;
                }
            },
            _value () {
                let v = '';
    
                if (typeof this.value !== 'undefined') {
                    v = this.value;
                } else if (this.value_getter) {
                    if (typeof this.$store.getters[this.value_getter] === 'function') {
                        v = this.$store.getters[this.value_getter](this.field);
                    } else {
                        v = this.$store.getters[this.value_getter];
                    }
                } else if (this.field) {
                    v = $younit._get(this.field);
                } else {
                    throw new Error('There is no value provided for tree select.');
                }
    
                return v;
            },
            _tree () {
                if (this.tree) {
                    return this.tree;
                } else if (this.tree_getter) {
                    return this.$store.getters[this.tree_getter](this.tree_getter_data);
                } else if (this.tree_field) {
                    return $younit._get(this.tree_field);
                }
    
                return throw new Error('NO SOURCE FOR THE TREE');
            },
            ticked () {
                if (!this.multi_select) {
                    return [];
                }
    
                return this._value || [];
            }
        },
        watch: {
            section_open: function (n, o) {
                this.load();
            },
            load_trigger: function (n, o) {
                this.load();
            }
        },
        methods: {
            hide_dialog () {
                setTimeout(() => {
                    this.dialog = false;
                }, 30);
            },
            focus () {
                this.$refs.field.focus();
            },
            async node_selected (v) {
                if (!this.sync_action) {
                    this.$emit('input', v);
                    if (this.close_after_select) {
                        this.hide_dialog();
                    }
                    return;
                }
    
                if (this.multi_select) {
                    // if (this.tickStrategy !== 'strict') {
                    //     // for now only this startegy is supported because the tick strategy of leaf I can not reproduce the behaviour
                    //     return;
                    // }
    
                    const ticked = [...this.ticked];
                    // const ticked = this.$refs.hidden_tree.getTickedNodes().map(item => item[this.tree_key]);
                    const index = ticked.indexOf(v);
                    if (index > -1) {
                        this.$refs.tree.setTicked([v], false);
                    } else {
                        this.$refs.tree.setTicked([v], true);
                    }
    
                    return;
    
                    // // selected node is already ticked
                    // if (ticked.indexOf(v) > -1) {
                    //     ticked.splice(index, 1);
                    // } else {
                    //     ticked.push(v);
                    // }
    
                    // // return this.node_ticked(this.ticked);
                    // // return;
                    // // return this.node_ticked(ticked);
                }
    
                try {
                    const old_value = this._value;
                    this.list_loading = true;
    
                    this.selected_node = this.$refs.hidden_tree.getNodeByKey(v);
    
                    if (this.confirm_action) {
                        this.persistent = true;
                        const rs = await this.$store.dispatch(this.confirm_action, {});
                        if (!rs) {
                            this.selected_node = this.$refs.hidden_tree.getNodeByKey(old_value);
                            this.list_loading = false;
                            this.persistent = false;
                            return;
                        }
                        this.persistent = false;
                    }
    
                    const sync_data = { ...{ [this.sync_selected_key]: v }, ...this.sync_data };
                    const r = await this.$store.dispatch(this.sync_action, sync_data);
    
                    if (r && r.r) {
                        this.hide_dialog();
                    } else {
                        this.selected_node = this.$refs.hidden_tree.getNodeByKey(old_value);
                    }
                } catch ($e) {
                    this.list_loading = false;
                    throw $e;
                }
    
                this.list_loading = false;
            },
            async node_ticked (v) {
                try {
                    const old_ticked_nodes = this.$refs.hidden_tree.getTickedNodes();
                    this.list_loading = true;
                    this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes();
    
                    if (this.confirm_action) {
                        this.persistent = true;
                        const rs = await this.$store.dispatch(this.confirm_action, {});
                        if (!rs) {
                            this.ticked_nodes = old_ticked_nodes;
                            this.list_loading = false;
                            this.persistent = false;
                            return;
                        }
                        this.persistent = false;
                    }
    
                    const sync_data = { ...{ [this.sync_selected_key]: v }, ...this.sync_data };
                    const r = await this.$store.dispatch(this.sync_action, sync_data);
    
                    if (r && r.r) {
                        this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes();
                    } else {
                        this.ticked_nodes = old_ticked_nodes;
                    }
                } catch ($e) {
                    this.list_loading = false;
                    throw $e;
                }
    
                this.list_loading = false;
            },
            async load () {
                if (this.section_open) {
                    this.loading = true;
    
                    let value_action_promise = true;
                    if (this.value_action) {
                        value_action_promise = this.$store.dispatch(this.value_action, this.value_action_data);
                    }
    
                    let tree_action_promise = true;
                    if (this.tree_action) {
                        tree_action_promise = this.$store.dispatch(this.tree_action, {});
                    }
    
                    await Promise.all([
                        value_action_promise,
                        tree_action_promise
                    ]);
    
                    this.loading = false;
                    this.key = Math.random();
                    this.hidden_key = Math.random();
                    if (this.multi_select) {
                        this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes();
                    } else {
                        this.selected_node = this.$refs.hidden_tree.getNodeByKey(this._value);
                    }
                }
            },
            // get_ticked_nodes (v) {
            //     const ticked_nodes = [];
            //     v.forEach((id) => {
            //         ticked_nodes.push(this.$refs.hidden_tree.getNodeByKey(id));
            //     });
            //     return ticked_nodes;
            // },
            on_focus () {
                // this.show_dialog();
            },
            on_keydown (e) {
                // 37 left 38 up, 39 right 40 down
                const $el = this.$refs.tree.$el;
                const $focus = $el.querySelector('.q-tree__node-header:focus');
                const all = [].slice.call($el.querySelectorAll('.q-tree__node-header'));
    
                let index = 0;
                const key = e.which;
    
                // down and up
                if (key === 40 || key === 38) {
                    if (key === 40) {
                        if ($focus) {
                            const i = all.indexOf($focus);
                            if (i !== all.length - 1) {
                                index = i + 1;
                            }
                        }
                    } else if (key === 38) {
                        if ($focus) {
                            const i = all.indexOf($focus);
                            if (i !== 0) {
                                index = i - 1;
                            } else {
                                index = all.length - 1;
                            }
                        } else {
                            index = all.length - 1;
                        }
                    }
                    all[index].focus();
                }
            },
            on_input () {
                this.show_dialog();
            },
            on_filter_keydown (e) {
                this.on_keydown(e);
            },
            on_field_click (e) {
                if (this.dialog) {
                    this.hide_dialog();
                } else {
                    this.show_dialog();
                }
                this.show_dialog();
            },
            on_field_keydown (e) {
                this.$emit('keydown', e);
    
                // down
                if ((e.keyCode === 40 || e.keyCode === 13) && !this.dialog) {
                    this.show_dialog();
                    setTimeout(() => {
                        this.$refs.filter.focus();
                    }, 100);
                }
    
                return true;
            },
            remove_chip (node_id) {
                if (this.multi_select) {
                    const ticked = [...this.ticked];
                    const index = ticked.indexOf(node_id);
                    ticked.splice(index, 1);
                    this.node_ticked(ticked);
                } else {
                    this.node_selected(null);
                }
            },
            show_dialog () {
                if (this.dialog) { return; }
                this.dialog = true;
                setTimeout(() => {
                    // this.$refs.filter.focus();
                }, 100);
            }
        }
    };
    
    </script>
    
    <style lang="stylus">
    
    .v_tree_select{
    
    }
    .v_tree_select .q-tree{
        width: 100%;
    }
    .v_tree_select_field_wrap{
        width: 100%;
    }
    .v_tree_select_dialog{
        // background-color: white;
    }
    .v_tree_select_dialog .q-dialog__backdrop{
        // display: none;
    }
    .v_tree_select_dialog .q-dialog__inner > div{
        box-shadow: none;
    }
    .v_tree_select_dialog .v_tree_select_popup_wrap{
         background-color: white;
         flex-wrap: nowrap;
    }
    .v_tree_select_dialog.q-menu .v_tree_select_dialog_close{
        display: none;
    }
    .v_tree_select_dialog .q-tree {
        margin: 16px;
    }
    .v_tree_select_filter_input{
        margin-bottom: 0 ;
    }
    </style>
    
    

    Screenshot 2020-08-20 at 16.56.58.png



  • Thanks. Maybe we just need to hack our way through this. Anyway, my approach seems to be working - just had to detect all the lost focus and click elsewhere events to hide the persistent tree.



  • @CWoodman said in Q-tree within Q-menu:

    (…) just had to detect all the lost focus and click elsewhere events to hide the persistent tree.

    does your solution works on the mobile, too?

    It seems, as there’s a lot of code in both solutions, it should be simple imho.
    It could mean, that there is a fundamental problem in Quasar abstractions if such use cases are hard to grasp 🙂



  • Yes it works on my phone. I agree it should be simpler!



  • It would help if you show some working code. Obviously not all use case are covered since it varies for each. the use case isn’t simple either.

    If you think there’s something missing on the component or its api that would help you, then it would help to make a feature request about it so that it can be improved if said feature is deemed logical to be added.



  • @metalsadman There’s a codepen of an earlier version at https://codepen.io/crawfordw/pen/abdrNRN but it doesn’t work correctly there for some reason.

    Anyway, what I needed was a Select component that would show a tree-view in the drop-down, and support autofill with items from any level of the tree.

    I don’t know if this is a general enough need to warrant building it into Quasar, but I intend to use it in many places in my app. I have it working well now, though needed to use a few kludgy techniques to get there.

    If you’re interested, I can provide the complete code here.



  • @metalsadman yeah, you’re obviously right: it needs specification. It is not a problem I need to solve just now, but it is an interesting case, and I always assumed that at some point of its way, Quasar will need an abstraction of “compound components”.

    We have something similar already - as in q-page or q-card, which are examples of compound components. Now, in this use case, OP encountered a need for overlayed, sibling information exchange, shared state component. His implementation in the context of framework provided abstractions, ended up complicated and not obvious.

    Maybe it’s about time to create a new abstraction in Quasar ie q-compound or q-overlayed which allows to build something like that?

    I think it is an excellent example, where app extensions are too heavy. If you wanted to build such light user-space and app-specific components as an AE, then this AE would be hard to maintain, debug, big and error prone.

    The needed q-compound should:

    • take care of events propagated and emitted
    • work in overlays / popups
    • support “decorator” interfaces (animations, transitions for example)
    • … and more?

    The actual code which OP used, is not elegant. I consider, that when framework forces you to write “not elegant” code, it is a strong sign of lacking abstractions in said framework. And Quasar is highly elegant. This is just a new use case which needs new abstraction.

    Honestly just a bunch of thoughts, hoping this would start a fruitful discussion.



  • @qyloxe Interesting discussion. Probably out of my depth on this. I’m just hacking up something that meets my UX needs. The original title of this post re: ‘tree within a menu’ doesn’t really reflect my starting point… Maybe I should have titled it ‘Challenges making a Select component that supports tree-structured options list’. If there was a native Quasar Select component that did that, I wouldn’t need to add a menu and a button and a bunch of styles and methods to make the whole mess look and behave like a regular Select box.



  • I personally feel this component should be part of the framework.

    It is very hard to avoid tree structures. Everybody wants categories and folders I find. So we will all start building our own tree select eventually and not very elegantly (or at least most of us).





  • Just added my code and description of how it works to the feature request post above



  • @turigeza you could see this as a tree select: its’a tree and you can select.

    https://quasar.dev/vue-components/tree#Example--Selectable-nodes

    Combine it with the filter example and you have the functionality of a q-select with a tree.




Log in to reply