Reputation: 5649
The current admin widget for ArrayField is one field, with comma as delimiter, like this (text list):
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
I could use another table to solve this, but I wonder if it's possible to solve it this way.
Upvotes: 16
Views: 9393
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
Upvotes: 0
Reputation: 34922
Unfortunately Django does not ship with a convenient widget for ArrayField
s 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
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