Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental Feature: New ImageBlock for StreamField (Work in Progress) #11791

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
25 changes: 25 additions & 0 deletions client/src/entrypoints/images/image-chooser-modified.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class ImageBlockDefinition extends window.wagtailStreamField.blocks
.StructBlockDefinition {
render(placeholder, prefix, initialState, initialError) {
const block = super.render(placeholder, prefix, initialState, initialError);

const altTextField = document.getElementById(prefix + '-alt_text');
const isDecorativeField = document.getElementById(prefix + '-decorative');
const updateStateInput = () => {
if (isDecorativeField.checked) {
altTextField.setAttribute('disabled', true);
} else {
altTextField.removeAttribute('disabled');
}
};
updateStateInput();
isDecorativeField.addEventListener('change', updateStateInput);

return block;
}
}

window.telepath.register(
'wagtail.images.blocks.ImageBlock',
ImageBlockDefinition,
);
1 change: 1 addition & 0 deletions client/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ module.exports = function exports(env, argv) {
'image-chooser',
'image-chooser-modal',
'image-chooser-telepath',
'image-chooser-modified',
],
'documents': [
'document-chooser',
Expand Down
18 changes: 15 additions & 3 deletions wagtail/blocks/field_block.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import datetime
from decimal import Decimal

Expand Down Expand Up @@ -817,11 +818,22 @@ def bulk_to_python(self, values):
"""Return the model instances for the given list of primary keys.

The instances must be returned in the same order as the values and keep None values.
If the same ID appears multiple times, a distinct object instance is created for each one.
"""
objects = self.model_class.objects.in_bulk(values)
return [
objects.get(id) for id in values
] # Keeps the ordering the same as in values.
seen_ids = set()
result = []

for id in values:
obj = objects.get(id)
if obj is not None and id in seen_ids:
# this object is already in the result list, so we need to make a copy
obj = copy.copy(obj)

result.append(obj)
seen_ids.add(id)

return result

def get_prep_value(self, value):
# the native value (a model instance or None) should serialise to a PK or None
Expand Down
114 changes: 113 additions & 1 deletion wagtail/images/blocks.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from django import forms
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.functional import cached_property

from wagtail.admin.compare import BlockComparison
from wagtail.blocks import ChooserBlock
from wagtail.blocks import BooleanBlock, CharBlock, ChooserBlock, StructBlock
from wagtail.blocks.struct_block import StructBlockAdapter, StructBlockValidationError
from wagtail.images.models import AbstractImage
from wagtail.telepath import register

from .shortcuts import get_rendition_or_not_found

Expand Down Expand Up @@ -51,3 +56,110 @@ def htmldiff(self):
"image_b": self.val_b,
},
)


class ImageBlock(StructBlock):
image = ImageChooserBlock(required=True)
decorative = BooleanBlock(
default=False, required=False, label="Image is decorative"
)
alt_text = CharBlock(required=False)

def get_searchable_content(self, value):
return []

def _struct_value_to_image(self, struct_value):
image = struct_value.get("image")
decorative = struct_value.get("decorative")
if image:
# If the image is decorative, set alt_text to an empty string
image.contextual_alt_text = (
"" if decorative else struct_value.get("alt_text", "")
)
image.decorative = decorative
return image

def to_python(self, value):
struct_value = super().to_python(value)
return self._struct_value_to_image(struct_value)

def bulk_to_python(self, values):
struct_values = super().bulk_to_python(values)
return [
self._struct_value_to_image(struct_value) for struct_value in struct_values
]

def value_from_datadict(self, data, files, prefix):
struct_value = super().value_from_datadict(data, files, prefix)
return self._struct_value_to_image(struct_value)

def clean(self, value):
if value is None:
raise StructBlockValidationError(
block_errors={
"image": ValidationError("Expected an image instance, got nothing")
}
)

if not isinstance(value, AbstractImage):
raise StructBlockValidationError(
block_errors={
"image": ValidationError(
"Expected an image instance, got %r" % value
)
}
)

if not value.contextual_alt_text and not value.decorative:
raise StructBlockValidationError(
block_errors={
"alt_text": ValidationError(
"Alt text is required for non-decorative images"
)
}
)

return value

def normalize(self, value):
if value is None or isinstance(value, AbstractImage):
return value
else:
struct_value = super().normalize(value)
return self._struct_value_to_image(struct_value)

def get_form_state(self, value):
return {
"image": self.child_blocks["image"].get_form_state(value),
"alt_text": value and value.contextual_alt_text,
"decorative": value and value.decorative,
}

def get_prep_value(self, value):
return {
"image": self.child_blocks["image"].get_prep_value(value),
"alt_text": value and value.contextual_alt_text,
"decorative": value and value.decorative,
}

def extract_references(self, value):
return self.child_blocks["image"].extract_references(value)

class Meta:
icon = "image"
template = "wagtailimages/widgets/image.html"


class ImageBlockAdapter(StructBlockAdapter):
js_constructor = "wagtail.images.blocks.ImageBlock"

@cached_property
def media(self):
structblock_media = super().media
return forms.Media(
js=structblock_media._js + ["wagtailimages/js/image-chooser-modified.js"],
css=structblock_media._css,
)


register(ImageBlockAdapter(), ImageBlock)
13 changes: 12 additions & 1 deletion wagtail/images/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode

objects = ImageQuerySet.as_manager()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_decorative = False
self.contextual_alt_text = None

def _set_file_hash(self):
with self.open_file() as f:
self.file_hash = hash_filelike(f)
Expand Down Expand Up @@ -618,6 +623,9 @@ def find_existing_renditions(
]
for rendition in Rendition.cache_backend.get_many(cache_keys).values():
filter = filters_by_spec[rendition.filter_spec]
# The retrieved rendition needs to be associated with the current image instance, so that any
# locally-set properties such as contextual_alt_text are respected
rendition.image = self
Chiemezuo marked this conversation as resolved.
Show resolved Hide resolved
found[filter] = rendition

# For items not found in the cache, look in the database
Expand Down Expand Up @@ -1218,7 +1226,10 @@ def url(self):

@property
def alt(self):
return self.image.default_alt_text
if self.image.is_decorative:
return ""
else:
return self.image.contextual_alt_text

@property
def attrs(self):
Expand Down
5 changes: 5 additions & 0 deletions wagtail/images/templates/wagtailimages/widgets/image.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% load wagtailimages_tags %}

<figure>
{% image value fill-600x338 loading="lazy" %}
</figure>
69 changes: 68 additions & 1 deletion wagtail/images/tests/test_blocks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import unittest.mock

from bs4 import BeautifulSoup
from django.apps import apps
from django.test import TestCase

from wagtail.images.blocks import ImageChooserBlock
from wagtail.blocks.struct_block import StructBlockValidationError
from wagtail.images.blocks import ImageBlock, ImageChooserBlock

from .utils import (
Image,
Expand Down Expand Up @@ -73,3 +75,68 @@ def test_extract_references(self):

# None should not yield any references
self.assertListEqual(list(block.extract_references(None)), [])


class TestImageBlock(TestImageChooserBlock):
def test_render(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": False,
}
html = block.render(block.to_python(value))
soup = BeautifulSoup(html, "html.parser")
img_tag = soup.find("img")

# check specific attributes
self.assertEqual(img_tag["alt"], value.get("alt_text"))
self.assertIn("/media/images/test.", img_tag["src"])

def test_render_as_decorative(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": True,
}
html = block.render(block.to_python(value))
soup = BeautifulSoup(html, "html.parser")
img_tag = soup.find("img")

# check specific attributes
self.assertEqual(img_tag["alt"], "")
self.assertIn("/media/images/test.", img_tag["src"])

def test_no_alt_text(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": None, # No alt text passed
"decorative": False,
}

# Use assertRaises as a context manager to check if a ValidationError is raised
with self.assertRaises(StructBlockValidationError) as context:
block.clean(block.to_python(value))
Comment on lines +119 to +121
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Use assertRaises as a context manager to check if a ValidationError is raised
with self.assertRaises(StructBlockValidationError) as context:
block.clean(block.to_python(value))
# No alt text is given and the image is not marked decorative, this is an invalid state and should raise an error
with self.assertRaises(StructBlockValidationError) as context:
block.clean(block.to_python(value))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip: don't explain what the code is doing. Instead, explain why it is doing something.

In this instance: why are we expecting an exception to be raised.


# Check the error message
self.assertIn(
"Alt text is required for non-decorative images",
str(context.exception.block_errors["alt_text"]),
)

def test_wrong_instance_type(self):
block = ImageBlock()
value = {"image": self.image.id, "alt_text": "Blank", "decorative": False}

# Use assertRaises as a context manager to check if a ValidationError is raised
with self.assertRaises(StructBlockValidationError) as context:
Comment on lines +133 to +134
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto the above

# pass in dict instead of normalized image instance
block.clean(value)

# Check the error message
self.assertIn(
"Expected an image instance, got %r" % value,
str(context.exception.block_errors["image"]),
)