-
Notifications
You must be signed in to change notification settings - Fork 32
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
RFC 94: Union Block #94
base: main
Are you sure you want to change the base?
Conversation
Note: supersedes #66 |
This is a really great RFC! I agree that it would be a very valuable addition to Wagtail, and you've very clearly presented why, and thoroughly explained how you would do it. Well done! One question to confirm my understanding/assumptions: For the link block example, in order to also set the text of the link, you would presumably suggest this sort of structure? class LinkBlock(StructBlock):
text = CharBlock()
link = UnionBlock([("page", PageChooserBlock()), ("url", URLBlock())]) (or I might suggest extending the examples presented to cover that complete link block replacement scenario. The only bit that I'm a little wary of is introducing this new UI paradigm of using a |
Thanks for the feedback @Scotchester ! Yes, the example you point out is incomplete (and your assumption is correct) - this is an oversight and I will update it. I will be very happy to receive guidance/feedback on the particulars of the UI/UX. |
This is a really good RFC. Love it! Could this possibly be extended to make it more dynamic? Why limit ourselves to just a single type. Maybe implement some sort of base class for the main logic (most of this is already in the streamblock); subclass that to filter out the item(s) on the javascript and use the unionblock class to achieve the same on the python side. This would then still allow a list of allowed types based on some arbitrary filter method instead of just a single type. It could be a list of one [type]. In this case - the "chooser" (radio, select, whatever); would "tell" the javascript adapter which type is allowed; the js definition (the subclass I mentioned) would then filter out to only allow that block type. This I think would also keep work minimal; as most of the logic for this would then already be implemented in the streamfield/streamblock. Keeps it extendable for developers 😉 As for the choicefield; my input is likely not really important - that's the UX guy's job, but I say no. Relevant slack discussion for extra infoMe
LB
Me
|
Hi @Nigel2392, thanks for the feedback. The functionality I'm proposing would allow for your use case, if I've understood it correctly. You would be able to define a block like this: class CardGroupChooser(UnionBlock):
card_option_1 = ListBlock(Card1())
card_option_2 = ListBlock(Card2())
card_option_3 = ... For each instance of
I have proposed making the chooser's widget customisable, so that developers are able to select a compatible widget other than the default (e.g. a Select widget). |
I was more thinking of something like this (rough sketch, heavily simplified, implementation would vary by a lot) class CardGroupChooser(UnionBlock):
# example for the __union_type__ input; just to make choices clear
option = blocks.ChoiceBlock(
choices=[
("happy", _("Happy")),
("sad", _("Sad")),
],
)
card_options = StreamBlock([
("card_option_1", Card1(condition="happy"])),
("card_option_2", Card2(condition="happy"])),
("card_option_2", Card2(condition="sad"])),
]) This would take away from it being a true union-like block; but would in turn give developers lots more options. This could even be taken one step further: class CardGroupChooser(UnionBlock):
option = blocks.ChoiceBlock(
choices=[
("happy", _("Happy")),
("sad", _("Sad")),
],
)
weather = blocks.ChoiceBlock(
choices=[
("sunny", _("Sunny")),
("rainy", _("Rainy")),
],
)
card_options = StreamBlock([
# Only show if we are happy
("card_option_1", Card1(conditions=["option.happy"])),
# Only show if the weather is rainy and we are happy
("card_option_2", Card2(conditions=["option.happy", "weather.rainy"])),
# Only show if we are sad
("card_option_3", Card3(conditions=["option.sad"])),
]) This would mean we could still get 2 different block types if the stars align. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solid proposal @jams2! Reads very well. I’d like to see more demonstration of how much this helps with the UX and the code. It’s clear to me there’s room for improvement on both fronts but not convinced the ROI is high enough with the proposal as-is?
I’d also like to see alternatives considered – how much thought have you given to other options? I have two in mind that seem to me like they’d have higher return on investment for UX and code improvements, but potentially score lower on API semantics:
- Implement similar generic improvements to what this suggests but in a backwards-compatible way.
- Tweak the StreamField UI for those "list of one item at most" use cases, perhaps add a way to define a default in StreamBlock.
- Introduce better support for conditional fields across Wagtail, perhaps utility methods to reduce the boilerplate.
- Work on Single interface for choosing links to anything #381 as a separate data type
There are two questions I’d also like to have an answer for:
- Are there a lot of other widespread enough use cases for this beyond links of different types?
- If this is primarily for links, is StreamField really the right approach or should we be looking at other options?
![Link block implemented stream block style](./assets/094/stream-style-link.png) | ||
|
||
The UI generated for inserting a link requires editors to first select the "+" button to insert a block, and then choose the block type. In the typical case that the link value is _required_ this creates dissonance between what is required by data validation and what is communicated to users by visual language - requiring users to insert a block when that block is required is a sub-par experience. Compare this to a link block implemented as a `UnionBlock`: | ||
|
||
![Link block implemented union block style](./assets/094/union-style-link.png) | ||
|
||
In this example all fields required to make a valid submission are immediately present in the UI, which clearly communicates the requirements of the system to users and prevents a class of validation errors from occurring. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The practical UX differences as I understand them:
- The StreamField block chooser is instead a radio group (or could be a
<select>
I suppose) - There’s a default link type selected, so we display the fields for that link type from the get-go
- There’s no disabled "+" button to add any more links of that type
If that’s it, I’m not clear why this couldn’t be done within existing StreamField capabilities?
And I’m also not convinced that the proposed UI is any clearer, because:
- Now selecting which set of fields is available via the "Link type" looks the same as any other field of a block.
- If the link is optional, the user would now have to remove the auto-added default block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The UI pattern that I want to get away from is having the options hidden away behind the "+" icon and needing that extra click to discover the options, when it could be implemented such that all of the information is available in the UI. The idea is that the UnionBlock
would be used for a more focused set of options than StreamBlock
, which can often become a "kitchen sink" of unrelated block types, and have a quantity of options such that displaying them persistently in the UI is unpractical.
I do feel that this proposed solution goes against the grain of the current blocks UI. When I wrote this, I had in mind the idea that users shouldn't have to interact with the page to reveal the information they need to accomplish their task.
class LinkBlock(blocks.StructBlock): | ||
title = blocks.CharBlock() | ||
link = LinkChooserBlock() | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it’d be nice if the examples really were equivalent. As-is I’m not clear if LinkStructValue
would look the same with UnionBlock
, or would be radically simpler.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The intention here is that a developer-defined LinkStructValue
wouldn't be necessary for common use cases of UnionBlock
. There is no longer a need to select an element of a sequence, and type specific logic could be pushed onto the constituent block types (e.g. in their get_context
method, or their individual templates).
link = LinkChooserBlock() | ||
``` | ||
|
||
This approach to the `LinkBlock` problem requires developers to write less code - significantly less when taking into account the custom JavaScript required when taking the approach illustrated by `wagtail-link-block`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wagtail-link-block
displays multiple fields from its StructBlock for specific link types, so I’m not convinced this is a fair comparison. If I understand this correctly, switching from StructBlock
to UnionBlock
would require duplicating those fields within multiple StructBlock
as options in the union?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sentence here is a little confusing, as "code that the developer of a particular Wagtail CMS instance needs to write" appears to be conflated with code that is part of a library.
That being said, fields shared by multiple members in a union could be implemented using StructBlock
inheritance, e.g.:
class BaseFooBlock(StructBlock):
a = SomeBlock()
b = SomeOtherBlock()
class FooBlock(BaseFooBlock):
# ... extra fields
class BarBlock(BaseFooBlock):
# ... extra fields
class FooBarUnion(UnionBlock):
foo = FooBlock()
bar = BarBlock()
In this example, both foo
and bar
would share the a
and b
fields without duplicating their declaration.
|
||
`UnionBlock` is a new block type that allows editors to select a block type from a set of types defined by the developer, and then insert a single value for that chosen type. | ||
|
||
For each instance of `UnionBlock`, Editors should be presented with a `ChoiceField`, with one option for each sub-block that is a member of the union. When they make a selection, the UI should be updated so that the native form widget for the selected block type is presented. Only a single form field for the block's value should ever be presented. A default value must always be provided, as an empty choice requires editors to make an interaction to reveal a form field for the value, when they have already made an interaction indicating that they wish to enter a value when they selected the `UnionBlock` (or the block containing it) in a `StreamBlock`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don’t understand how this would work for optional links 🤔. For example here:
class CTABlock(blocks.StructBlock):
text = blocks.CharBlock()
link = LinkChooserBlock(required=False)
If LinkChooserBlock
was a UnionBlock
, it’d need an explicit default of "EmptyBlock"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is an oversight. We would have to allow for optional members, so an empty choice would need to be available — it could have a label like "Please select a link type". Perhaps this should be tweaked to say something along the lines of "If the UnionBlock
is a required sub-block, a default value must always be provided..." — my initial line of thought was to minimise the number of interactions required by the editor to enter a value.
I think we could use null
to represent "no choice" in an optional UnionBlock
at the JSON level.
|
||
The serialised value of a `UnionBlock` should be a JSON object, with the following required keys: | ||
|
||
- `type` - the name of the `UnionBlock`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One feature of the block described in RFC 66 was compatibility with the StreamBlock
JSON representation. So the following types would have the same representation in the JSON:
StreamBlock({
"paragraph": ParagraphBlock(),
"image": ImageChooserBlock(),
})
ListBlock(EnumBlock({
"paragraph": ParagraphBlock(),
"image": ImageChooserBlock(),
})
I thought this would allow us to refactor StreamBlock
to be a special case of ListBlock
so all sequences in streamfields could be handled the same way, and potentially make features like migrations commments, etc easier to implement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kaedroho This probably isn't feasible, now that ListBlock itself has a StreamBlock-like JSON representation where the items are blocks (dicts with an id
attribute and type="item"
), rather than plain values. To make the representations match, ListBlock would need to include a special case for when its child block is a UnionBlock and drop the extra wrapper.
Thanks for the feedback @thibaudcolas! I'll address a couple of points from your parent post here.
I feel that this would be missing an opportunity to introduce a new, useful, fundamental building block that addresses a common rough corner in user code. I do think the core value here is provided by having unions present in the "stream field type system", so that developers don't have to shoe-horn data that is best modeled as a union into a sequence type. I do agree that having a "link type" in Wagtail could be beneficial, but even if that were implemented, I would still advocate for the introduction of
Speaking from my own experience, I've seen other uses of |
Adds an RFC for adding a
UnionBlock
to Wagtail.rendered
Related issues: