Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface StringSegment extends SegmentCommon {
type: 'string';
source: string;
editUrl: string;
previousTranslation?: PreviousTranslation;
}

export interface SynchronisedValueSegment extends SegmentCommon {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -317,17 +312,57 @@ const SegmentFieldLabel = styled.h4`
const SegmentSource = styled.p`
padding: 15px 20px;
font-style: italic;

&.title {
font-size: 1.875rem;
font-weight: 800;
line-height: 1.3;
}
`;

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;
Expand All @@ -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,
Expand All @@ -365,24 +399,19 @@ 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks like the closing } for &--green got removed here along with the blank lines. Without it, &--red ends up nested inside &--green, so it'd compile to .icon--green--red instead of .icon--red — which would break the red error icon styling.

Copy link
Copy Markdown
Author

@zanzo2003 zanzo2003 Mar 23, 2026

Choose a reason for hiding this comment

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

Hey @Phinart98 , thanks for pointing that out !! totally missed that brace. I have updated the PR. Please let me know if there is anything else ? Thanks!

}

&--red {
color: #cd3239;
Expand Down Expand Up @@ -467,6 +496,21 @@ const EditorStringSegment: FunctionComponent<EditorStringSegmentProps> = ({
setEditingValue((translation && translation.value) || '');
};

if (!translation && segment.previousTranslation && !isLocked) {
const onClickReusePrevious = () => {
setEditingValue(segment.previousTranslation?.value || '');
setIsEditing(true);
};

buttons.push(
<li key="reuse">
<ActionButton onClick={onClickReusePrevious}>
{gettext('Reuse previous translation')}
</ActionButton>
</li>
);
}

if (translation && translation.comment) {
comment = (
<>
Expand Down Expand Up @@ -520,6 +564,49 @@ const EditorStringSegment: FunctionComponent<EditorStringSegmentProps> = ({
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 = (
<PreviousTranslationNotice>
<PreviousTranslationHeading>
{gettext('Previous translation')}
</PreviousTranslationHeading>
<PreviousTranslationText>
{previous.value}
</PreviousTranslationText>
{previous.source && (
<PreviousTranslationSource>
{gettext('Source at the time: %s').replace(
'%s',
previous.source
)}
</PreviousTranslationSource>
)}
{(commentText || previous.translatedBy?.avatar_url) && (
<PreviousTranslationMeta>
{previous.translatedBy?.avatar_url ? (
<Avatar
username={previous.translatedBy.full_name}
avatarUrl={previous.translatedBy.avatar_url}
/>
) : null}
<span>{commentText}</span>
</PreviousTranslationMeta>
)}
</PreviousTranslationNotice>
);
}

return (
<li className={className}>
{segment.location.subField && (
Expand All @@ -530,6 +617,7 @@ const EditorStringSegment: FunctionComponent<EditorStringSegmentProps> = ({
<SegmentSource className={valueClassName}>
{segment.source}
</SegmentSource>
{previousTranslationNotice}
<SegmentValue>{value}</SegmentValue>
<SegmentToolbar>
<li key="comment">{comment}</li>
Expand Down
59 changes: 59 additions & 0 deletions wagtail_localize/tests/test_edit_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 58 additions & 4 deletions wagtail_localize/views/edit_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand All @@ -689,6 +742,7 @@ def edit_translation(request, translation: Translation, instance):
},
),
"order": segment.order,
"previousTranslation": previous_translation_data,
}
)

Expand Down