Bojan Kogoj
Bojan Kogoj

Reputation: 5649

Django admin custom ArrayField widget

The current admin widget for ArrayField is one field, with comma as delimiter, like this (text list):

Current admin ArrayField widget

This isn't ideal because I would have longer texts (even 20 words) and contain commas. I could change the delimiter to be something else but that still doesn't help with unreadable content in admin.

What I would like is having a list of fields, that I can alter in admin. Something similar to the following image

Wanted admin ArrayField widget

I could use another table to solve this, but I wonder if it's possible to solve it this way.

Upvotes: 16

Views: 9393

Answers (3)

Mehrdad995
Mehrdad995

Reputation: 47

Better-admin-arrayfield is no longer maintained and supported in Django 4.1+
One alternative could be using django-jsonform but not specifically.
If you want to have an Arrayfield of Integers with separated fields for a specific model, one hack is to achieve it using JS and CSS. To do so, you need to first create a CSS and JS file in your i.e., static folder.
Mine is like:

static
├css
| └admin
|    └task-item.css
└js
  └admin
     └task-item.js

Use the normal ArrayField of PostgreSQL in the Model.py file:

from django.contrib.postgres.fields import ArrayField

class TaskItem(models.Model):
    # To avoid <<default should be a callable>> warning @ migration #
    def get_rewards_default():
        return list((1000,))
    #... maybe some other fields
    # considering the field you want to be effected is named reward #
    reward = ArrayField(models.IntegerField(default=1000, null=False, blank=False), default=get_rewards_default)

In admin.py add Media class and write the path to your created files like the following:

class TaskItemAdmin(admin.ModelAdmin):
    ...

    class Media:
        js = ('js/admin/task-item.js',)    
        css = {
             'all': ('css/admin/task-item.css',)
        }

Use the following code in task-item.js file:

// We hook to the <DOMContentLoaded> to make sure all DOM elements are loaded.
window.addEventListener('DOMContentLoaded', ()=>{
    // ===== Define variables ===== //
    // The container that holds reward field's Label and Input elements
    const container = document.querySelector('.form-row.field-reward .flex-container');
    // Django creates two Input elements for the ArrayField one with type <text> and another with type <hidden>, we want the one with type <text>
    const input_def = container.querySelector('input[type=text]');
    // Read stored array values from the default input value which is a comma seperated string
    const init_values = input_def.value.split(',');
    // We have to convert our values back to comma separated string before saving it in the database
    let compiled_str = ''
    // Define a function to compile our values to a comma separated string
    const compile_to_str = ()=>{
        compiled_str = ''
        input_elements = container.querySelectorAll('input[type=text]');
        input_elements.forEach((input, i) => {
            // If its the last value, dont add <,> at the end
            compiled_str += (String(input.value) + ((i < input_elements.length - 1) ? ',' : ''));
        })
        // Set the newly compiled value back to the default input element django created at start
        input_def.value = compiled_str;
    }

    // ========================================================= //

    // Hide the default input element
    input_def.type = 'hidden';

    // For each starting value of our field create an <input> element
    init_values.forEach(value => {
        if (!value){
            return;
        }
        const input = document.createElement('input');
        input.type = 'text';
        input.value = value;
        container.append(input);
    });


    // ### Event Listener Assignment for the Given Element ### //
    const addEventListeners = (input) => {
        input.addEventListener('dragstart', (e) => {
            draggedInput = input;
            input.classList.add('dragging');
            e.dataTransfer.effectAllowed = 'move'; // Crucial for drag and drop
        });

        input.addEventListener('dragover', (e) => {
            e.preventDefault(); // Prevent default to allow drop
        });

        input.addEventListener('dragenter', (e) => {
            e.preventDefault(); // Prevent default to allow drop
        });

        // Place element accordingly after drag and drop
        input.addEventListener('drop', (e) => {
            e.preventDefault();
            if (input !== draggedInput) {
                const parent = input.parentNode;
                const draggedIndex = Array.from(parent.children).indexOf(draggedInput);
                const targetIndex = Array.from(parent.children).indexOf(input);

                parent.insertBefore(draggedInput, targetIndex > draggedIndex ? input.nextSibling : input);
            }
        });

        input.addEventListener('dragend', () => {
            draggedInput.classList.remove('dragging');
            draggedInput = null;
            // At the end of the reordering re calculate the comma separated string from individual input elements
            compile_to_str();
        });

        input.addEventListener('input', ()=>{
            // On input elements modified, re calculate the comma separated string from individual input elements
            compile_to_str();
        })

        input.addEventListener('change', ()=>{
            // On input elements change, re calculate the comma separated string from individual input elements and remove the element if its empty
            if (!input.value){
                input.remove();
                compile_to_str();
            }
        })
    } 

    // ### Assign EventListeners to Each Input Element ### //
    const inputs = container.querySelectorAll('input[type=text]');
    let draggedInput = null;
    inputs.forEach(input => {
        addEventListeners(input);
    });
    
    // ### Create Add Element BUTTON ### //
    const add_button = document.createElement('div');
    add_button.id = 'add_reward_input';
    add_button.innerHTML = 'Add';
    add_button.addEventListener('click', ()=>{
        const new_input = container.querySelector('input[type=text]:last-of-type').cloneNode();
        container.insertBefore(new_input, container.querySelector('input[type=text]:last-of-type'));
        // ### Assign EventListeners to Newly Created Input ### //
        addEventListeners(new_input);
        compile_to_str();
    })
    container.append(add_button);
})

And in task-item.css file use this code for visualization purposes:

#add_reward_input
{
    font-family: monospace;
    font-weight: bolder;
    font-size: 1rem;
    width: 75px;
    height: 100%;
    background-color: #6d6;
    border: 1px solid #393;
    border-radius: 5px;
    align-self: center;
    text-align: center;
    margin: 0 5px;
    padding: 5px;
    cursor: pointer;
    transition: all 0.1s linear;
}
#add_reward_input:hover
{
    background-color: #6f6;
}
#add_reward_input:active
{
    scale: 1.05;
}

Usage

  • You can add new elements to the array by using the Add button
  • You can also re-order elements by selecting each value and dragging them over other values (it's a little clunky as you have to highlight at least part of the value before drag for it to work)
  • To remove an element, just erase its value and press Enter

end result showing arrayfield

Upvotes: 0

Antoine Pinsard
Antoine Pinsard

Reputation: 34922

Unfortunately Django does not ship with a convenient widget for ArrayFields yet. I'd suggest you to create your own. Here is an example for Django>=1.11:

class DynamicArrayWidget(forms.TextInput):

    template_name = 'myapp/forms/widgets/dynamic_array.html'

    def get_context(self, name, value, attrs):
        value = value or ['']
        context = super().get_context(name, value, attrs)
        final_attrs = context['widget']['attrs']
        id_ = context['widget']['attrs'].get('id')

        subwidgets = []
        for index, item in enumerate(context['widget']['value']):
            widget_attrs = final_attrs.copy()
            if id_:
                widget_attrs['id'] = '%s_%s' % (id_, index)
            widget = forms.TextInput()
            widget.is_required = self.is_required
            subwidgets.append(widget.get_context(name, item, widget_attrs)['widget'])

        context['widget']['subwidgets'] = subwidgets
        return context

    def value_from_datadict(self, data, files, name):
        try:
            getter = data.getlist
        except AttributeError:
            getter = data.get
        return getter(name)

    def format_value(self, value):
        return value or []

Here is the widget template:

{% spaceless %}
<div class="dynamic-array-widget">
  <ul>
    {% for widget in widget.subwidgets %}
      <li class="array-item">{% include widget.template_name %}</li>
    {% endfor %}
  </ul>
  <div><button type="button" class="add-array-item">Add another</button></div>
</div>
{% endspaceless %}

A few javascript (using jQuery for convenience):

$('.dynamic-array-widget').each(function() {
    $(this).find('.add-array-item').click((function($last) {
        return function() {
            var $new = $last.clone()
            var id_parts = $new.find('input').attr('id').split('_');
            var id = id_parts.slice(0, -1).join('_') + '_' + String(parseInt(id_parts.slice(-1)[0]) + 1)
            $new.find('input').attr('id', id);
            $new.find('input').prop('value', '');
            $new.insertAfter($last);
        };
    })($(this).find('.array-item').last()));
});

And you would also have to create your own form field:

from itertools import chain

from django import forms
from django.contrib.postgres.utils import prefix_validation_error

class DynamicArrayField(forms.Field):

    default_error_messages = {
        'item_invalid': 'Item %(nth)s in the array did not validate: ',
    }

    def __init__(self, base_field, **kwargs):
        self.base_field = base_field
        self.max_length = kwargs.pop('max_length', None)
        kwargs.setdefault('widget', DynamicArrayWidget)
        super().__init__(**kwargs)

    def clean(self, value):
        cleaned_data = []
        errors = []
        value = filter(None, value)
        for index, item in enumerate(value):
            try:
                cleaned_data.append(self.base_field.clean(item))
            except forms.ValidationError as error:
                errors.append(prefix_validation_error(
                    error, self.error_messages['item_invalid'],
                    code='item_invalid', params={'nth': index},
                ))
        if errors:
            raise forms.ValidationError(list(chain.from_iterable(errors)))
        if cleaned_data and self.required:
            raise forms.ValidationError(self.error_messages['required'])
        return cleaned_data

Finally, set it explicitly on your forms:

class MyModelForm(forms.ModelForm):

    class Meta:
        model = MyModel
        fields = ['foo', 'bar', 'the_array_field']
        field_classes = {
            'the_array_field': DynamicArrayField,
        }

Upvotes: 22

tsrandrei
tsrandrei

Reputation: 89

Try to take a look in this one :

Better ArrayField admin widget?

I think is more about a js thing after you have rendered the Array in a different way.

Upvotes: 3

Related Questions