diff --git a/wagtail_localize/static_src/editor/components/TranslationEditor/index.tsx b/wagtail_localize/static_src/editor/components/TranslationEditor/index.tsx index c7eeef9e..a3314c38 100644 --- a/wagtail_localize/static_src/editor/components/TranslationEditor/index.tsx +++ b/wagtail_localize/static_src/editor/components/TranslationEditor/index.tsx @@ -80,6 +80,7 @@ export interface StringSegment extends SegmentCommon { type: 'string'; source: string; editUrl: string; + previousTranslation?: PreviousTranslation; } export interface SynchronisedValueSegment extends SegmentCommon { @@ -131,6 +132,14 @@ export interface StringTranslation { translatedBy: User | null; } +export interface PreviousTranslation { + value: string; + source: string | null; + comment: string | null; + translatedBy: User | null; + updatedAt: string | null; +} + export interface SegmentOverrideAPI { segment_id: number; data: any; diff --git a/wagtail_localize/static_src/editor/components/TranslationEditor/segments.tsx b/wagtail_localize/static_src/editor/components/TranslationEditor/segments.tsx index c5efa681..6744729d 100644 --- a/wagtail_localize/static_src/editor/components/TranslationEditor/segments.tsx +++ b/wagtail_localize/static_src/editor/components/TranslationEditor/segments.tsx @@ -269,7 +269,6 @@ const BlockSegments = styled.ul` background-color: var(--w-color-surface-header, var(--w-color-grey-50)); padding: 0; margin: 0; - > li { &.errored { background-color: var(--w-color-critical-50, #fee7e8); @@ -278,21 +277,17 @@ const BlockSegments = styled.ul` border: 1px solid var(--w-color-critical-100) !important; border-left-width: 5px !important; } - &.incomplete { // !important required to override the border-bottom rule just below border-left: 5px solid var(--w-color-warning-100) !important; } - &.complete { // !important required to override the border-bottom rule just below border-left: 5px solid var(--w-color-positive-100) !important; } - &:not(:last-child) { border-bottom: 1px solid var(--w-color-border-furniture, #eeeeee); } - &:after { content: ''; display: table; @@ -317,7 +312,6 @@ const SegmentFieldLabel = styled.h4` const SegmentSource = styled.p` padding: 15px 20px; font-style: italic; - &.title { font-size: 1.875rem; font-weight: 800; @@ -325,9 +319,50 @@ const SegmentSource = styled.p` } `; +const PreviousTranslationNotice = styled.div` + padding: 12px 20px; + background-color: var( + --w-color-warning-50, + var(--w-color-secondary-50, #fef4e5) + ); + border-top: 1px solid + var(--w-color-warning-100, var(--w-color-secondary-100, #f7d8a0)); + border-bottom: 1px solid + var(--w-color-warning-100, var(--w-color-secondary-100, #f7d8a0)); +`; + +const PreviousTranslationHeading = styled.span` + display: block; + font-weight: 600; + color: var(--w-color-text-emphasis, var(--w-color-grey-700, #333333)); +`; + +const PreviousTranslationText = styled.p` + margin: 0.4em 0 0; + font-style: italic; + font-weight: 600; +`; + +const PreviousTranslationMeta = styled.small` + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + color: var(--w-color-text-subtle, var(--w-color-grey-600, #666666)); + .avatar { + width: 1.75rem; + height: 1.75rem; + } +`; + +const PreviousTranslationSource = styled.p` + margin: 0.5em 0 0; + font-size: 0.9em; + color: var(--w-color-text-subtle, var(--w-color-grey-600, #666666)); +`; + const SegmentValue = styled.div` padding: 0.9em 1.2em; - > p, > ${StyledTextArea} { font-size: 1.2em; @@ -350,7 +385,6 @@ const ActionButton = styled.button` var(--w-color-surface-button-default, var(--w-color-secondary-100)); border-radius: 2px; padding: 5px 10px; - &:hover { background-color: var( --w-color-surface-button-hover, @@ -365,21 +399,17 @@ const SegmentToolbar = styled.ul` text-align: right; padding: 10px; margin: 0; - > li { display: inline-block; - &:not(:first-child) { margin-left: 15px; } } - .icon { width: 1.3em; height: 1.3em; vertical-align: text-bottom; margin-left: 10px; - &--green { color: #15704d; } @@ -467,6 +497,21 @@ const EditorStringSegment: FunctionComponent = ({ setEditingValue((translation && translation.value) || ''); }; + if (!translation && segment.previousTranslation && !isLocked) { + const onClickReusePrevious = () => { + setEditingValue(segment.previousTranslation?.value || ''); + setIsEditing(true); + }; + + buttons.push( +
  • + + {gettext('Reuse previous translation')} + +
  • + ); + } + if (translation && translation.comment) { comment = ( <> @@ -520,6 +565,49 @@ const EditorStringSegment: FunctionComponent = ({ valueClassName = 'title'; } + let previousTranslationNotice = <>; + if (!translation && segment.previousTranslation) { + const previous = segment.previousTranslation; + const commentText = + previous.comment || + (previous.translatedBy + ? gettext('Previously translated by %s').replace( + '%s', + previous.translatedBy.full_name + ) + : null); + + previousTranslationNotice = ( + + + {gettext('Previous translation')} + + + {previous.value} + + {previous.source && ( + + {gettext('Source at the time: %s').replace( + '%s', + previous.source + )} + + )} + {(commentText || previous.translatedBy?.avatar_url) && ( + + {previous.translatedBy?.avatar_url ? ( + + ) : null} + {commentText} + + )} + + ); + } + return (
  • {segment.location.subField && ( @@ -530,6 +618,7 @@ const EditorStringSegment: FunctionComponent = ({ {segment.source} + {previousTranslationNotice} {value}
  • {comment}
  • diff --git a/wagtail_localize/tests/test_edit_translation.py b/wagtail_localize/tests/test_edit_translation.py index 4bbe5a16..caa601b1 100644 --- a/wagtail_localize/tests/test_edit_translation.py +++ b/wagtail_localize/tests/test_edit_translation.py @@ -1792,6 +1792,65 @@ def test_edit_page_translation_from_translated_page_show_convert_to_alias_button reverse("wagtail_localize:convert_to_alias", args=[de_page.id]), ) + def test_segments_include_previous_translation_when_source_changes(self): + # Provide an initial human translation for the text field + original_string = String.objects.get(data="A text field") + context = TranslationContext.objects.get( + object=self.page_source.object, + path="test_textfield", + ) + StringTranslation.objects.create( + translation_of=original_string, + context=context, + locale=self.fr_locale, + data="Un champ de texte", + translation_type=StringTranslation.TRANSLATION_TYPE_MANUAL, + last_translated_by=self.user, + ) + + # Update the source content to trigger a new segment + self.page.test_textfield = "A text field updated" + self.page.save_revision().publish() + self.page_source.update_from_db() + + response = self.client.get( + reverse("wagtailadmin_pages:edit", args=[self.fr_page.id]) + ) + self.assertEqual(response.status_code, 200) + + props = json.loads(response.context["props"]) + + target_segment = next( + segment + for segment in props["segments"] + if segment["contentPath"] == "test_textfield" + ) + + self.assertEqual(target_segment["source"], "A text field updated") + self.assertIsNone(target_segment["location"]["subField"]) + + # No translation exists yet for the updated string + self.assertIsNone( + next( + ( + translation + for translation in props["initialStringTranslations"] + if translation["segment_id"] == target_segment["id"] + ), + None, + ) + ) + + previous_translation = target_segment["previousTranslation"] + self.assertIsNotNone(previous_translation) + self.assertEqual(previous_translation["value"], "Un champ de texte") + self.assertEqual(previous_translation["source"], "A text field") + self.assertIsNotNone(previous_translation["comment"]) + self.assertEqual( + previous_translation["translatedBy"]["full_name"], + self.user.get_full_name(), + ) + @freeze_time("2020-08-21") class TestPublishTranslation(EditTranslationTestData, APITestCase): diff --git a/wagtail_localize/views/edit_translation.py b/wagtail_localize/views/edit_translation.py index 31a853eb..b0c6d43f 100644 --- a/wagtail_localize/views/edit_translation.py +++ b/wagtail_localize/views/edit_translation.py @@ -610,8 +610,31 @@ def edit_translation(request, translation: Translation, instance): return redirect(request.path) - string_segments = translation.source.stringsegment_set.all().order_by("order") - string_translations = string_segments.get_translations(translation.target_locale) + string_segments_qs = ( + translation.source.stringsegment_set.select_related("context", "string") + .all() + .order_by("order") + ) + string_translations = string_segments_qs.get_translations( + translation.target_locale + ).select_related("last_translated_by", "translation_of") + string_segments = list(string_segments_qs) + + segment_context_ids = [segment.context_id for segment in string_segments] + previous_translations_by_context = defaultdict(list) + if segment_context_ids: + historical_translations = ( + StringTranslation.objects.filter( + locale=translation.target_locale, + context_id__in=segment_context_ids, + ) + .select_related("translation_of", "last_translated_by") + .order_by("context_id", "-updated_at") + ) + for historical_translation in historical_translations: + previous_translations_by_context[historical_translation.context_id].append( + historical_translation + ) overridable_segments = translation.source.overridablesegment_set.all().order_by( "order" @@ -648,8 +671,14 @@ def edit_translation(request, translation: Translation, instance): # Set to the ID of a string segment that represents the title. # If this segment has a translation, the title will be replaced with that translation. - with contextlib.suppress(StringSegment.DoesNotExist): - title_segment_id = string_segments.get(context__path="title").id + title_segment_id = next( + ( + segment.id + for segment in string_segments + if segment.context.path == "title" + ), + None, + ) machine_translator = None translator = get_machine_translator() @@ -674,6 +703,30 @@ def edit_translation(request, translation: Translation, instance): except FieldHasNoEditPanelError: continue + previous_translation_data = None + for previous_translation in previous_translations_by_context.get( + segment.context_id, [] + ): + if previous_translation.translation_of_id == segment.string_id: + continue + + previous_translation_data = { + "value": previous_translation.data, + "source": previous_translation.translation_of.data, + "comment": previous_translation.get_comment(), + "translatedBy": UserSerializer( + previous_translation.last_translated_by + ).data + if previous_translation.last_translated_by + else None, + "updatedAt": ( + previous_translation.updated_at.isoformat() + if previous_translation.updated_at + else None + ), + } + break + segments.append( { "type": "string", @@ -689,6 +742,7 @@ def edit_translation(request, translation: Translation, instance): }, ), "order": segment.order, + "previousTranslation": previous_translation_data, } )