在django3.1的admin中加入jQuery单选下拉树dropdown Combo tree Select widget

亢建木
2023-12-01

首先感谢 早晨阳光一般暖 (https://blog.csdn.net/LZY_1993)分享了这么棒的组件代码,才让我们这些伸手党可以只做自己最拿手的事儿。哈哈

https://blog.csdn.net/LZY_1993/article/details/107854344
http://wsitm.gitee.io/web_test/view/TestSelectZTree.html

======================================================

我们遇到的需求中有一个字段要从近两千行分层数据中选择底级数据,在主界面用React+(Antd TreeSelect)+Django-Rest的组合解决非常简单,支持的特性也很丰富。

https://ant.design/components/tree-select-cn/

唯一不足的是不支持XP下chrome最高的49.0.2623.112版本。于是想只用django3.1的admin提供一个极简的修改入口,但是在网上搜来搜去,居然没找到一篇讲django admin中如何支持下拉树型组件的文章。

网上高手们提供的下拉树型组件很多,但是没有django admin可以直接用的。如果脱离admin,或前后端分离的解决方案又不符合自己的初衷。

我们知道django3.1中admin的autocomplete特性可以生成一个Select2下拉组件,可以搜索但是不支持树型结构,选项不能按层级折叠,选项太多就不好用了

https://dev.to/connorbode/builtin-select2-widget-in-django-admin-3ip

在网上找到了最接近这个需求的项目是django-treewidget,pip只能安装组件,而Github中下载的源码包含一个exampleapp可以用来直观感受一下

https://github.com/netzkolchose/django-treewidget

试用后就会发现django-treewidget基于jstree实现了丰富的树型结构特性,但是不支持下拉,树组件直接显示在主界面上,随着展开或折叠自动变更组件高度。

临时应对可以通过CSS限制高度,并加入overflow: auto支持滚动条就勉强能用了

/*Lib\site-packages\treewidget\static\treewidget\default.css*/
.treewidget {
    border: 1px solid #ddd;
    border-radius: 5px;
    min-height: 24px;
    overflow: auto;
    max-height: 120px;
}

问题在于用到inline页面时行高过大,max-height高度设定太小又影响树的可操作性。

对前端javascript高手来说,可能随手就能将其加入下拉组件中,但是在网上没找到相关文章,自己修改javascript实现又没达到那种水平。

我们只能硬着头皮试着将网上找来的jQuery下拉树组件整合进django admin了。

总结需求如下:

必需的特性
一、支持XP下chrome最高的49.0.2623.112版本
二、要支持inline admin中多个实例的状态,并且能保存(废话)
三、初始状态可自定义折叠,除了第一级展开外,其余层折叠,否则太长了,没有树的意义了
四、末级才可选,非末级点击或双击只能展开或折叠
五、初始状态从数据库取出的当前字段值要能显示在未下拉时的组件框中
六、placeholder的行inline admin保存时也要视为空值,不会保存无意义的行
七、不能与django中的autocomplete(Select2)、django-treewidget(jstree)、django.jQuery有冲突
八、不能和optgroup一样耍流氓仅支持两个层级

非必需的特性
一、支持搜索,搜索结果,展开其所在的所有上级节点以显示层级关系
二、选项值多来源支持(1)options(2)java Array(3)json(4)django model(5)ajax
三、初始状态从数据库取出的当前字段值在下拉树中也被选中,展开其所在的所有上级节点,
    并且滚动定位到可见区域(django-treewidget中已实现)
四、点击下拉框以外自动退出,而不是必须选一个选项或点击下拉按钮所在的Select框才退出
五、两千条下的组件性能,不应该有用户可感觉的延迟
六、风格与django admin的Select组件类似

一、组件的选择

一、https://github.com/charljmert/select2gtree
不算是树,类似https://ant.design/components/cascader-cn/,但是下拉后不向右扩展,提供了一个回
退按钮,不符合用户操作习惯,并且当前选项不易定位,放弃

二、https://github.com/maliming/select2-treeview
在cn.bing.com国际版搜索django select2 tree跳出来的第一个Stack Overflow网页
https://stackoverflow.com/questions/56128355/how-to-display-a-tree-in-select2
中推荐的No.2,看了一下example.png,应该是只支持两层,放弃

三、https://github.com/erhanfirat/combo-tree
Demo演示地址https://www.jqueryscript.net/demo/Drop-Down-Combo-Tree/
介绍https://www.jqueryscript.net/form/Drop-Down-Combo-Tree.html
看Demo发现是初始全展开,设置json数据结构太简单,只有id, title, subs,想当然认为不能自定义折叠,
放弃

四、https://github.com/patosai/tree-multiselect.js
Demo演示地址https://patosai.com/tree-multiselect
看Demo发现也是初始全展开,配置参数中collapsible按字面理解是设置可展开节点而不是初始折叠状态,高
度怀疑不能自定义折叠,而且为什么有这么多文件,要自己编译么?放弃

五、https://github.com/clivezhg/select2-to-tree
在cn.bing.com国际版搜索django select2 tree跳出来的第一个Stack Overflow网页中推荐的No.1,终于
是初始折叠第二层级的了,也可以指定非末级节点只能展开不能选择(见Example 3),但有明显的缺陷:一是
网页上没有说明如何用函数设置当前值,用option selected实测(Example 2)设置初始值在django admin
中可行,用var mydata中设selected:"true"也许可行但没试,因为这得要在django模板中相对复杂地处理;
二是性能实在是一言难尽,也许需要多选时可以考虑,单选还是决定放弃了

六、https://github.com/lonlie/select2tree
一号备选select2gtree写明了是forked from本项目,试了一下demo.html,除了每个节点第一次展开要点两
次鼠标比较奇怪以外感觉还不错,文档中提供了函数调用设置当前值的方法,实测也可以用option selected
设置初始值,我都已经把整个整合工作都做了一遍后才意识到还是有两个问题:一是二层以下都是初始展开的,
这个好像得改select2tree.js才可能改变此行为,因为依赖的css和js比较少,也算是先从容易的入手预演了
整个整合过程,对于两层以上但选项不太多的也可以采用。二是无法设置非末级节点只能展开不能选择,放弃

七、https://blog.csdn.net/crazypandariy/article/details/102766928
这个我也试了进行整合,但是只进行了几步就意识到有几个问题。一是无法设置非末级节点只能展开不能选择,
这个完全可以在数据提交时校验出来;二是评论区有的用户要修改代码才能支持一页多个实例;三是下拉后点下
拉框以外并不会退出,必须点下拉按钮所在的选项框才能退出,四是Select2可能会和django autocomplete
冲突。留待以后当Select2和ztree学习资料收藏了,放弃

八、https://blog.csdn.net/LZY_1993/article/details/107854344
Demo演示地址http://wsitm.gitee.io/web_test/view/TestSelectZTree.html
1、终于看到var zNodes可以通过open: true自定义初始折叠状态了
2、默认二层以下全折叠
3、用函数可以设置当前值
4、Demo本身就证明三个实例互不干扰
5、可搜索,搜索结果,展开其所在的所有上级节点以显示层级关系
6、不是用Select2,不会与Django autocomplete冲突,与Django的冲突可能性只在jQuery上了
7、用的ztree,不会与django-treewidget使用的jstree冲突
8、除了没有多选外,非必需的特性 二 没做到,更不能动态更新,但是仅从静态值来说,通过Django
   的模板,可以从django-treewidget维护的mptt或treebeard直接取初始值
9、非必需的特性都只有 三 完全没做到,几乎是实现中能找到的最符合整合需求的了

二、准备django3.1演示环境,实际使用环境在linux中,图简单本文中就在64位win7中演示了,所有js、py和templetes中如果有汉字,必须用UTF-8编码保存,后文不再强调

1、安装python-3.8.8-amd64.exe

2、安装virtualenv:  >pip install virtualenv -i https://mirrors.aliyun.com/pypi/simple/

3、图简单从网上已有项目开始https://github.com/linevych/django-admin-listview-inlines

   下载django-admin-listview-inlines-master.zip在桌面解压目录更名为django-admin-listview-inlines

4、必备步骤

django-admin-listview-inlines>virtualenv myenv
django-admin-listview-inlines>myenv\Scripts\activate

(myenv) django-admin-listview-inlines>pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
(myenv) django-admin-listview-inlines>python manage.py migrate
(myenv) django-admin-listview-inlines>python manage.py createsuperuser

5、编辑tabular_inline_in_list_view\settings.py

#设置中文支持
LANGUAGE_CODE = 'zh-Hans'
TIME_ZONE = 'Asia/Shanghai'
USE_TZ = False

#设定static文件目录
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static')
]

6、在django-admin-listview-inlines目录内准备各目录和templetes
新建目录结构
app1\templates\admin\app1\ParentModel
app1\templates\app1
static\lzy_1993\img

(myenv) django-admin-listview-inlines>copy
myenv\Lib\site-packages\django\forms\templates\django\forms\widgets\select.html
app1\templates\app1\customfield1select.html

(myenv) django-admin-listview-inlines>copy
myenv\Lib\site-packages\django\contrib\admin\templates\admin\change_form.html
app1\templates\admin\app1\ParentModel\change_form.html

(myenv) django-admin-listview-inlines>copy
myenv\Lib\site-packages\django\contrib\admin\templates\admin\base.html
templates\admin\base.html

7、编辑app1\models.py后migrate

#为了测试方便允许field1值为空
field1 = models.CharField(max_length=255, null=True, blank=True, default='')

#在数据库中生效
(myenv) django-admin-listview-inlines>python manage.py makemigrations
(myenv) django-admin-listview-inlines>python manage.py migrate

三、 自定义某个model字段的特定class

参考文章1:https://timonweb.com/django/override-field-widget-in-django-admin-form/
参考文章2:https://blog.ihfazh.com/django-custom-widget-with-3-examples.html

编辑app1\admin.py,定制ChildModel.field1的widget加入class customfield1select-widget

注释掉以下两行
#class ChildModelInline(admin.TabularInline):
#    model = ChildModel

在这两行的位置增加以下代码

from django.forms import ModelForm
from django.forms.widgets import Select

class Customfield1Select(Select):
    class Media:
        css = {
            'all': ("/static/lzy_1993/jquery.select.zTree.v1.2.css",
            "/static/lzy_1993/zTreeStyle.css",)
        }
        js = ("/static/lzy_1993/jquery-1.9.1.min.js",
        "/static/lzy_1993/jquery.ztree.core.js",
        "/static/lzy_1993/jquery.ztree.exhide.js",
        "/static/lzy_1993/jquery.select.zTree.v1.2.min.js",
        "/static/lzy_1993/zNodes.js",)
    def __init__(self, attrs=None, choices=(), *args, **kwargs):
        super().__init__(attrs=None, choices=(), *args, **kwargs)
        self.attrs = {"style": "min-width: -webkit-fill-available;"}
    template_name =  'app1/customfield1select.html'

class ChildModelAdminForm(ModelForm):
    class Meta:
        model = ChildModel
        widgets = {
            'field1': Customfield1Select(),
        }
        fields = ['field1','field2','field3',]
    def __init__(self, *args, **kwargs):
        super(ChildModelAdminForm, self).__init__(*args, **kwargs)
        for name, field in self.fields.items():
            if field.widget.__class__ == Customfield1Select:
                if 'class' in field.widget.attrs:
                    field.widget.attrs['class'] += ' customfield1select-widget'
                else:
                    field.widget.attrs.update({'class':'customfield1select-widget'})


class ChildModelInline(admin.TabularInline):
    model = ChildModel
    form = ChildModelAdminForm

四、准备好js和css文件

下载
http://wsitm.gitee.io/web_test/static/zTree_v3/css/zTreeStyle/zTreeStyle.css
http://wsitm.gitee.io/web_test/static/selectZTree/jquery.select.zTree.v1.2.css
http://wsitm.gitee.io/web_test/static/jquery/jquery-1.9.1.min.js
http://wsitm.gitee.io/web_test/static/zTree_v3/js/jquery.ztree.core.js
http://wsitm.gitee.io/web_test/static/zTree_v3/js/jquery.ztree.exhide.js
http://wsitm.gitee.io/web_test/static/selectZTree/jquery.select.zTree.v1.2.min.js
保存到static\lzy_1993目录

下载zTreeStyle.css中url引用的
http://wsitm.gitee.io/web_test/static/zTree_v3/css/zTreeStyle/img/line_conn.gif
http://wsitm.gitee.io/web_test/static/zTree_v3/css/zTreeStyle/img/zTreeStandard.png
http://wsitm.gitee.io/web_test/static/zTree_v3/css/zTreeStyle/img/zTreeStandard.gif
http://wsitm.gitee.io/web_test/static/zTree_v3/css/zTreeStyle/img/loading.gif
保存到static\lzy_1993\img目录

五、参考myenv\Lib\site-packages\django\contrib\admin\static\admin\js\autocomplete.js编辑static\unregistered_handlers.js

参考文章:https://docs.djangoproject.com/en/3.1/ref/contrib/admin/javascript/

//static\unregistered_handlers.js
'use strict';
{
    // const $ = django.jQuery;
    const init = function($element, options) {
        // const settings = $.extend({
        //     ajax: {
        //         data: function(params) {
        //             return {
        //                 term: params.term,
        //                 page: params.page
        //             };
        //         }
        //     }
        // }, options);
        // $element.select2(settings);
        $element.selectZTree({
            data: zNodes,
            width: 250,
            showSearch: true,
            selectLevel: -1, //没有子节点的节点的才能选择
            withInitValue: false,//设置初始值
            closeOnSelect: true,
            placeholder: "---------",
            /*
            onReady: function (ele) {
                console.log(ele)
                console.log("onReady")
            },
            onOpen: function (ele) {
                console.log(ele)
                console.log("onOpen")
            },
            onClose: function (ele) {
                console.log(ele)
                console.log("onClose")
            },
            onSelected: function (ele, val) {
                console.log(ele)
                console.log(val)
                console.log("onSelected")
            }
            */
        })
        .on("change", function (e, data) {
            // console.log(e);
            // console.log(data)
            console.log($(this).val())
        });
    };

    // $.fn.djangoAdminSelect2 = function(options) {
    $.fn.djangoAdminselectZTree = function(options) {
            const settings = $.extend({}, options);
        $.each(this, function(i, element) {
            const $element = $(element);
            init($element, settings);
        });
        return this;
    };

    // $(function() {
    //     // Initialize all autocomplete widgets except the one in the template
    //     // form used when a new formset is added.
    //     $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2();
    // });

    $(function() {
        // Initialize all autocomplete widgets except the one in the template
        // form used when a new formset is added.
        $('.customfield1select-widget').not('[name*=__prefix__]').djangoAdminselectZTree();
    });

    // $(document).on('formset:added', (function() {
    //     return function(event, $newFormset) {
    //         return $newFormset.find('.admin-autocomplete').djangoAdminSelect2();
    //     };
    // })(this));

    django.jQuery(document).on('formset:added', function(event, $row, formsetName) {
        // Row added
        $($row).find('.customfield1select-widget').not('[name*=__prefix__]').djangoAdminselectZTree();
    });

    django.jQuery(document).on('formset:removed', function(event, $row, formsetName) {
        // Row removed
    });

}

需要说明的是参考文章中说明django自带的jQuery使用django.jQuery命名空间,而我们将使用的组件的jquery是全局空间

 因此注释掉// const $ = django.jQuery;否则不能调用组件

文件尾部

django.jQuery(document).on('formset:added', function(event, $row, formsetName) {

如果写成

$(document).on('formset:added', function(event, $row, formsetName) {

则无法通过formset:added触发,因为这个event是在django.jQuery命名空间被fired

我们在这里困扰了好久,这个函数不生效会导致inline动态增加的新行中组件不会被替换。

编辑app1\templates\admin\app1\ParentModel\change_form.html

#加入unregistered_handlers.js
{% block admin_change_form_document_ready %}
    ...省略原模板内容...
    <script type="text/javascript" src="/static/unregistered_handlers.js"></script>
{% endblock %}

六、当前数据库取值设定为下拉组件的当前值

参考文章:https://stackoverflow.com/questions/298772/django-template-variables-and-javascript

编辑app1\templates\admin\app1\ParentModel\change_form.html

#初始化一个全局变局为一个Array
{% block extrahead %}{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
<script>
  window.customfield1 = {}
</script>
{{ media }}
{% endblock %}

编辑app1\templates\app1\customfield1select.html

<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</select>

{% if "" in widget.value %}
{% else %}
<script>
 console.log( "id_"+"{{widget.name|stringformat:'s'}}"+" "+{{widget.value|safe|stringformat:'s'}}[0])
 window.customfield1["id_"+"{{widget.name|stringformat:'s'}}"]={{widget.value|safe|stringformat:'s'}}[0]
</script>
{% endif %}

需要说明两点:一、删掉了<select>和</select>之间和option相关的部分,我们不需要;二、没找到id的模板引用方法,还好找到了一个规律:id=‘id_'+name,就用了一个笨办法 ”id_"+{{widget.name|stringformat:'s'}}

又编辑app1\templates\admin\app1\ParentModel\change_form.html,为每个数据库值不为空的设置初始值

{% block admin_change_form_document_ready %}
    ...省略原模板内容...
    <script type="text/javascript" src="/static/unregistered_handlers.js"></script>
<script>
 // console.log(window.customfield1)
$(document).ready(function () {
    //$(".customfield1select-widget").selectZTreeSet();
    $.each(window.customfield1, function(i, element){
        //console.log(window.customfield1[i])
        $("#"+i).selectZTreeSet(window.customfield1[i]);
    })
});
</script>
{% endblock %}

新建static\lzy_1993\zNodes.js

是从 http://wsitm.gitee.io/web_test/view/TestSelectZTree.html 源码中抄来的,但是加了一行 id: “”,不加这一行则会缺省选中第一个可选中值(根据selectLevel决定,-1则是 id: 111,不设selectLevel则是id: 1)

        var zNodes = [
            {id: "", pId: 0, name: "---------"},
            {id: 1, pId: 0, name: "父1 - 展开", open: true},
            {id: 11, pId: 1, name: "父11 - 折叠"},
            {id: 111, pId: 11, name: "叶子节点节点节点111"},
            {id: 113, pId: 11, name: "叶子节点节点节点113"},
            {id: 1131, pId: 113, name: "叶子节点节点节点1131"},
            {id: 1132, pId: 113, name: "叶子节点节点节点1132"},
            {id: 1133, pId: 113, name: "叶子节点节点节点1133"},
            {id: 114, pId: 11, name: "叶子节点节点节点114"},
            {id: 12, pId: 1, name: "父12 - 折叠"},
            {id: 121, pId: 12, name: "叶子节点节点节点121"},
            {id: 122, pId: 12, name: "叶子节点节点节点122"},
            {id: 123, pId: 12, name: "叶子节点节点节点123"},
            {id: 124, pId: 12, name: "叶子节点节点节点124"},
            {id: 13, pId: 1, name: "父13"},
            {id: 2, pId: 0, name: "父2 - 折叠"},
            {id: 21, pId: 2, name: "父21 - 展开", open: true},
            {id: 211, pId: 21, name: "叶子节点节点节点211"},
            {id: 212, pId: 21, name: "叶子节点节点节点212"},
            {id: 213, pId: 21, name: "叶子节点节点节点213"},
            {id: 214, pId: 21, name: "叶子节点节点节点214"}
        ];

七、测试

(myenv) django-admin-listview-inlines>python manage.py runserver

八、测试没问题后将field1值重新定义为不为空

编辑app1\models.py后migrate

field1 = models.CharField(max_length=255, null=False, blank=False, default='')

#在数据库中生效
(myenv) django-admin-listview-inlines>python manage.py makemigrations
(myenv) django-admin-listview-inlines>python manage.py migrate

十、改为使用django.jQuery与autocomplete的冲突解决

改为使用django.jQuery

将以下三个文件
jquery.ztree.core.js
jquery.ztree.exhide.js
jquery.select.zTree.v1.2.min.js
尾部的jQuery改为django.jQuery

将unregistered_handlers.js文件开头的
const $ = django.jQuery; 行去掉注释

编辑app1\admin.py
将Media中引用的'/static/lzy_1993/jquery-1.9.1.min.js',
改为引用 'admin/js/jquery.init.js',

编辑app1\templates\admin\app1\ParentModel\change_form.html修改以下部分
<script>
 // console.log(window.customfield1)
django.jQuery(document).ready(function () {
    //$(".customfield1select-widget").selectZTreeSet();
    django.jQuery.each(window.customfield1, function(i, element){
        //console.log(window.customfield1[i])
        django.jQuery("#"+i).selectZTreeSet(window.customfield1[i]);
    })
});
</script>

'admin/js/jquery.init.js'的加入是因为测试时发现如果引入了autocomplete特性,则出报错,原因是Media引入js的顺序问题,加到这里确保在。jquery.ztree.core.js,jquery.ztree.exhide.js,jquery.select.zTree.v1.2.min.js之前就没有问题了。

本文收关了。

 类似资料: