Problem/Motivation
The changes in content_moderation/translation and how it handles non-translatable fields broke paragraphs pretty badly. See #2951436: Fix integration with content moderation in multi-lingual scenarios, based on some reports we saw, it also affects non content-moderation workflows (as in, non-workflow workflows. Excuse the bad wordplay, it is late).
The thing with paragraphs is that we display references entities, which have their own revisions and translations in the same form. The field itself is untranslatable (at least that's currently the only supported mode), and we lock down all changes that would result in a change on the field itself, to make sure referenced paragraph id/revision id and order can not change across translations. But we do allow the change the corresponding translations and revisions in the referenced paragraphs and their fields.
A lot of that keeping-in-sync stuff happens directly in Entity Reference Revisions which supports the concept of "composite entities" that only exist in the context of a parent and automatically get a new revision if the parent does.
That means we're technically compatible with the limitations that core has on non-translatable fields, the logic in content_translation/entity storage just doesn't know about it because all it knows is the isTranslatable() flag on the field.
Proposed resolution
There are a few steps that are necessary here to make things work. Per discussion in this issue, each problem has been moved to a separate issue.
* First we need to get around the fact that \Drupal\content_translation\ContentTranslationHandler::entityFormSharedElements() is hiding our widget on the form. I found a workaround for that, but I think it would be easier if widgets could just set #multilingual themself and content_translation_form_alter() would not override it. This might also help with some hacks we have right now to get rid of the translatability clue message.
#2975754: Add hooks to act on a new revision being created
* Then we need to prevent the field from being replaced with the default revision/translation values in \Drupal\Core\Entity\ContentEntityStorageBase::createRevision().
#2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes
* While we don't want the paragraph field values to be replaced, we basically need to be able to apply the same/similar logic to our referenced entities. So when a new "merge-revision" of the node is created, we want to do the same to the paragraphs, so that they too merge their translatable/untranslatable fields together. And later on when we have fancy conflict resolution that will replace this special case, we'll need to be able to apply that recursively as well.
#2975762: Respect existing #multilingual property in content_translation_form_alter()
Remaining tasks
User interface changes
API changes
Data model changes
| Comment | File | Size | Author |
|---|
Comments
Comment #2
plachThanks for opening this, the suggested plan looks great to me at first sight:
I vaguely remember to have briefly tried something similar and failed but +1 on this in principle :)
The original plan when porting Entity Translation to D8 was to make
#multilingualan official key (see also #1498724: Introduce a #multilingual key (or similar) for form element), so whatever goes in that direction is welcome.I already found a couple of use cases that would require the addition of an
hook_entity_create_revision()hook (see #2940204-37: Translatable fields with synchronization enabled should behave as untranslatable fields with respect to pending revisions and #2941736: Moderation state revisions should have their isDefaultRevision() match the host entity's), so I think this could be the correct approach.My main concern is that whenever we change a paragraph entity the parent reference is updated with the new revision, which causes all translations to be marked as affected (or only the default one, depending on the site settings). I think we also need a way to make sure that only the active translation is marked as affected. That may be achievable by forcing the RTA flag value via this new hook.
I think this could also be addressed by the
hook_entity_create_revision()hook: if paragraphs iterated over referenced children and made the new revision objects available in the form, it should be possible to apply this logic recursively.Comment #3
plachComment #4
plachComment #5
berdirJust some quick changes to get started with the necessary things needed by #2951436: Fix integration with content moderation in multi-lingual scenarios.
As commented, running into some problems that are actually not too specific to paragraphs.
This part.
Comment #6
berdirUpdated patch that passes the already cloned entity for the new revision to the hook, so that I can mis-use that in #2961399: Support parallel translation forward revisions on untranslatable fields. Just trying to get to a point where I have something working-ish, I'm not proposing this as the actual API ;)
Comment #7
berdirTalking to @plach, he was very certain that /latest should indeed work like the test there expects it.
Turns out this is indeed because of the paragraphs or more specifically because of the untranslated field in general. Started working on a concept that allows a field to report if a translation has changes or not and using that in ERR to forward the call to the referenced entity.
Obviously everything is still super early, undocumented and untested (in core, some tests are in the ERR/paragraphs patches)
I'll also try to provide interdiffs from now on, but the patch is still pretty small.
Comment #8
plachNice!
I'm wondering whether it would be possible to use
EntityChangesDetectionTraitand move this alter hook over there (and statically cache the result).Comment #9
berdirThe problem is that for configurable fields, it depends on the bundle, and I'm currently also misusing the hook to make actual changes to the entity, although that's likely temporary.
While looking at this, I also noticed a good amount of additional fields that we could skip by default, like the id, uuid, bundle and langcode field as those shouldn't ever be synced or don't need to be synced.
Comment #10
plachYep, statically caching by entity type / bundle of course. And then we can introduce the revision creation hook to create merged revisions for paragraphs.
Comment #11
berdirMaybe, not sure how often this is called and if it's really worth it, there's nothing complicated going on there? Saving an entity is a very slow process, getting those keys seems trivial in comparison.
Yeah, I'm not sure yet if that create_revision hook should be a hook or maybe also a method on the field, possibly the same as I'm using for the language thingy. While they are conceptually not too related, but it seems that the chance is quite high that if you mess with one you also need to mess with another. Another option for that would be to add the methods we need to the existing interface/base class and move the logic there for all fields (we could also remove the special case for ChangedFieldItemList then for example).
Very open to suggestions, I just did the simplest thing I could think of to get it working.
Comment #12
plachComment #13
berdirSetting #multilingual in the langugae widget, allows us to remove two special cases of it.
Moving the hasTranslationChanges() method into FieldItemListInterface, moving the default implementation there and overriding in ChangedFieldItemList,.
Using the existing EntityChangesDetectionTrait trait in the storage instead of duplicating the logic.
Some of these changes might be considered BC breaks, I'm happy to move them back as deprecated or so, just trying to see how this could work together.
I think the primary remaining part is now the skip fields hook and how that shoud work exactly. Whether that should also be a method on the field item list class, or if the hook should alter sync fields instead of skipped fields. And how exactly we should split the fields alter and making the change on the referenced paragraph entities.
Comment #14
plachNo longer around :)
Now that I had a closer look at this code, I think that I did not use
EntityChangesDetectionTraithere previously because the meanings of::getRevisionTranslationMergeSkippedFieldNames()andgetRevisionTranslationMergeSkippedFieldNamesare slightly different and I wasn't sure whether they would always share the same logic, should we have to update it in the future.However, it seems unlikely that they are currently sharing it by chance, so I'm wondering what's the common denominator between these two use cases:
::getLoadedRevisionId(); if we consider also the ERR use case, basically in this case we want skip any field that always varies by revision (is revision-specific);ChangedItemis involved in this case, I believe here we are more interested in a data vs metadata split, and revision-specific fields are normally metadata.So, maybe that's the only common bit? Both need to identify revision-specific fields to implement two distinct business logics?
Per the previous bullet, I'd keep this method around, and just retrieve the result of
::getFieldsToSkipFromTranslationChangesCheck()from within that. I guess we will need to add an$entityparameter and make it optional to preserve BC. Or we could change::getFieldsToSkipFromTranslationChangesCheck()to accept an $entity_type instead of an $entity, it's an @internal trait so this should be fine.This could be simplified to
return !$this->getFieldDefinition()->isComputed() && !$this->equals($original_items):)If this is going to be one of the first building blocks of a change detection/resolution API, maybe it would make more sense not to mention translation specifically, as this method can be used on any item to detect "actual" / relevant / data-affecting changes.
Why do we need to specify a
$langcodeparameter? Can't we rely on the field language itself? Or the parent entity's active language?By moving this key here we are making it an "official" core key, so I guess we should document it somewhere. As an alternative we could alter the widget itself from CT via
hook_field_widget_WIDGET_TYPE_form_alter.Comment #15
berdirThanks for the review.
1. Removed, also the other one that we no longer use now.
2. & 3. Yeah, completely reverted that. With the new approach that we discussed to have a single hook_entity_revision_create_alter(), we can just undo the wrong things that the default implementation does with minimal overhead and the hook is far less confusing. Figuringing out the similarity/difference between the method that we have here and the trait is something for another day/issue. Also started with the hook documentation.
4. Indeed.
5. Tried to improve the docs, not an easy thing to explain. I still used translation as an example in the second paragraph. For the record, I think that the way we treat the changed field is super strange, we basically have a workaround for a workaround now (changed field by default not always properly detecting a change.. there is a big overlap between translation affected and changed...)
6. As discussed, for untranslatable fields.
7. Still todo, agreed that it should be documented somewhere but no idea where yet. Don't really understand the second part, I think we need to keep #multilingual on the level where it is, other code might already mess with it (I started with that, with my own callback that did run before the one from CT)
I think we're getting close with the implementation, needs more documentation and tests.
Comment #16
plachCan we make this a regular entity hook like
hook_entity_create(), thus also supporting the entity type-specific version?That is
$this->invokeHook('revision_create', ...). We can add a$contextparam or implement dynamic argument forwarding.Also, extra blank line :)
typo
Not sure this adds much value to the previous line :)
We could may mention the revision translation merge and this being the right place to mess with its logic?
Or skip the line altogether :)
Missing $context
Right now it's an alter hook, but I'd keep this way for consistency with
hook_entity_create.Perfect, we can expand on this if/when the method becomes useful in other contexts.
I meant that we could do this as an alternative, if we don't want to make the key "officially part of core" yet:
Extra blank line :)
Comment #17
berdirThanks for the review.
Mostly addressed, as discussed, did not us the invokeHook() helper methods as that would break custom implementations if we'd add support for additional arguments there. Agreed on it not being an alter hook, that was a left-over of it being the skip fields alter hook.
Not yet changing the #multilingual thing, I'd prefer documenting it somewhere over that approach as then it would actually be easier to just keep it in the existing form alter.
Added some tests for the revision create hook. Also testing if the #multilingual behavior of the language widget is tested.
Comment #18
plachLooks good!
Minor can we store the arguments in variable and pass that to
::invokeAll()?Sorry, missed that: "Responds" :)
"the revision from" would sound better to me.
Maybe we could modified that to say "Whether untranslatable field values were kept or copied from the default revision when generating a merged revision." otherwise this seems prescriptive whereas all the work has been done and hook implementors are free to do what they need to.
Shouldn't this test be part of the CRUD entity test?
:)
Can we add assertions also for
$keep_untranslatable_fieldsflag?Comment #19
borisson_Based on #18, I'm changing this issue to needs work.
Comment #20
berdir1. Fixed. I also noticed in testing (the ERR patch was not broken but paragraphs was, so the ERR test coverage is not complete yet, I think because we do not have an already published translation on the node.) that $keep_untranslatable had the wrong value as we set it to TRUE in the loop and now call the hooks after the loop. Instead now I'm passing along the original value, so it is NULL unless specifically set by the caller. There would also be the option to always pass a boolean value but the value from above the loop. That might be easier for hooks as otherwise they would need to re-detect a NULL themself?
2. IMHO Respond is correct. hook documentations should not use third person becaus you are the one acting on it, not someone else. Our existing docs are inconsistent, hook_ENTITY_TYPE_create() above has "Acts", but hook_entity_load() below has "Act".
3. Agreed, changed.
4. Yeah, wondered about that but wasn't sure what exactly to write. Changed.
5. Possibly, I just copied it from one there, You mean EntityCrudHookTest? That doesn't do multilingual yet, so also not a perfect match, but happy to move there or into another test if you have a better example.
6. The :) is for "it" I assume? That's actually simply because I copied the test method from above as a starting point.
7. I'll add that, but I guess we should first clarify what exactly we want to pass (see 1.), then I'll add assertions for it.
I also found in my recent tests that validation started to fail. Possibly that was a side effect of the incorrect revision copying above but anyway, I think we also want to use the new method in the hasTranslationChanges() counter-part in EntityUntranslatableFieldsConstraintValidator that looks for non-translation-changes.
Also, sadly the patch in #17 did not fail, but we discussed that and it means that those #multilingual changes on the language field are actually bogus, they were added in the very first content_translation patch that added the module and back then, the language field was actually not properly translatable yet. It is now, so we can remove that. It however does also mean that we do need a test widget that sets that key.
Comment #21
plachThis is only one use case: what about changing the comment to a more generic "Notify modules about the new revision"?
Nice clean-up, we can remove this comment now.
We no longer need the
usestatement forChangedFieldItemList.Variable names are a bit of a mess now :) Can we stick with
$items/$original_items?Comment #22
plachRe #20:
1: As we discussed, I think the approach of just forwarding what was passed is correct, otherwise we lose that information, while the default value can be computed again, although that means duplicating the related logic. We should probably explicitly document the expected default in the PHP docs, both in the hook and in
TranslatableRevisionableStorageInterface::createRevision().2: You're absolutely correct: https://www.drupal.org/node/1354#hooks :)
5: Nevermind, I thought we had a test covering all CRUD hooks, including multilingual ones.
6: Thanks for not being nice to me :))
7: Ok, still to do per bullet 1.
Re the
#multilingualkey, it sucks but I agree we need a test widget.Comment #23
plachComment #24
berdir#21
1. Sure.
2. Removed.
3. Removed.
4. Updated, also made hasTranslationChanges() consistent.
#22
1. Ok, tried to document it a bit better, slightly different sentence on both
I also added a test for #multilingual now. I found out that I can put it into an override of the public form() method, then it is top-level and we don't need that trickery anymore in the alter hook. I also looked for a place to better document it but didn't really find anything that works. I thought about adding it to whever untranslatable_fields.default_translation_affected is documented but as far as I can see, that is not actually documented anywhere :)
Comment #26
plachNice, I think we are down to nits, the test failure seems trivial to fix.
Missing trailing dot.
This paragraph was not updated.
typo: "as multilingual"
Can we assert also the
keep_untranslatable_fieldsflag?We should able able to use
$this->statehere.Comment #27
berdirThanks again for the review.
Fixed the test fail, extend the new tests for the hook and fixed the nitpicks.
Comment #28
plachLooks great to me, I think we just need a CR now.
Comment #29
tstoecklerThis seems to be a duplicate of #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes. I'm fine with closing that one instead of this one as this one is further along now in terms of test coverage, etc. But I would request 2 things here:
Comment #30
plach+1 on #29.
Comment #31
hchonovThis doesn't work for the default translation, as then
$this->activeLangcodehas the value ofLanguageInterface::LANGCODE_DEFAULT. This has been solved in the similar issue already - #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes. Basically we need the following transformation:$langcode = ($this->activeLangcode == LanguageInterface::LANGCODE_DEFAULT) ? $this->defaultLangcode : $this->activeLangcode;Wouldn't be better to define this property in the field widget annotation and then take care of setting it in
WidgetBase::form()? I would prefer this, as this way we kind of provide an official API for the property by having to introduce it in\Drupal\Core\Field\Annotation\FieldWidget.@berdir said in slack:
I don't know what a committer would decide, but the current issue is solving three different problems. I personally would separate the concepts and if desired we still might publish only one MR. If we do this, then we already have the issue which is introducing the similar method to
FieldItemListInteface::hasAffectingChanges()- #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes, which means that we would need only one additional issue for the new hooks and the current issue might take care only of the#multilingualproperty.Comment #32
plach@hchonov, #31:
1. Good catch, we will need test coverage for that.
2. Berdir and I were wondering about that as well, there is also a dedicated issue for it: #1498724: Introduce a #multilingual key (or similar) for form element. I think that should be our ultimate goal, however once we make the
#multilingualkey official, we need to take all the most common scenarios into account. For instance it may no longer be used only by the CT module, in fact it would make little sense for core to expose a key that's only used by one module. Hence I think it would make sense to just adapt the CT's code while addressing this issue, and then focus on a more complete solution over there.I also suggested in Slack that it might make sense to split this issue, however, given that we already have one to officially introduce
#multilingual, I'm wondering whether it would be fine to merge the::hasAffectingChanges()bits into #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes, and split the rest into two separate issues: one to introduce the revision hooks and one to adapt CT's use of#multilingual.Comment #34
catchMarked #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes as duplicate. Adding credit for hchonov and mkalkbrenner.
Comment #35
berdirSplitting up it is.
Created two new issues for the easy parts:
* #2975754: Add hooks to act on a new revision being created
* #2975762: Respect existing #multilingual property in content_translation_form_alter()
Now I'll need to look into merging the remaining things into #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes
Comment #36
berdirThat issue now also has a patch.
Comment #37
plachCrediting also Tobias for the research work :)
Comment #38
plachComment #39
jasonawantHi,
I'm a little confused about the state of this issue, its patches and its related issues and their patches.
#34 states the following, but is that accurate? I don't think so.
In #35, this issue was converted into a meta issue pointing to the other 3 issues with #36 issue summary changes and patch in related issue.
I think I've figured it out. I'm disabling the display of the files in this issue. It looks we should use the patch files found in the other issues referenced in the issue summary. Let me know if hiding the files is not preferred or I've misunderstood something here.
Comment #40
jasonawanthiding other files
Comment #41
berdirSee https://www.drupal.org/docs/8/modules/paragraphs/multilingual-and-conten... on up to date instructions on which patches to use and how to configure things.
Comment #42
jasonawantThanks!
Comment #43
gábor hojtsyI committed #2975754: Add hooks to act on a new revision being created so #2826021: FieldItemList::equals is sufficient from the storage perspective but not for code checking for changes is left of this, right?
Comment #44
hchonov@Gábor Hojtsy, yes that is correct.
Comment #46
berdirAwesome, all 3 issues are in, so we can commit the patches to ERR & Paragraphs, thanks everyone!
I also published the change record and replaced the part that Gabor already published in a separate change record with a link to that.
Note that while paragraphs will now work, there are still issues with some more advanced field types/widgets, for example the image field in core, see #2988309: Ensure that all field types return TRUE on equals() for the same values.