Skip to content

Commit f86fa92

Browse files
KevinJumpclaude
andauthored
Fix: move property out of group on import (#1009) (#1010) (#1043)
* Fix: move property out of group on import (#1009) Importing a content type where a property has been moved out of all groups (empty <Tab> and no matching <Tabs> entry) did nothing - the property stayed in its original group. DeserializePropertiesAsync only handled properties moving *into* a tab. When the tab alias resolved to empty for an existing property, the else branch fell through without action. Now, when an existing property has no tab in the config but is still in a group, it is queued to move to "no group" via MovePropertyType(alias, null), which Umbraco treats as removing it from its current group. * Fix: re-home property into no-group so move persists in one import (#1009) Moving a property out of all groups still needed two imports. Root cause: Umbraco's MovePropertyType(alias, null) removes the property from its group but does NOT add it to the 'no group' collection - it orphans the property, so the first import's save doesn't persist the move. MoveProperties now re-adds the property with AddPropertyType after the move to null, which lands it in NoGroupPropertyTypes. Added unit tests documenting the orphaning behaviour and verifying the workaround. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c9c2a7a commit f86fa92

2 files changed

Lines changed: 109 additions & 4 deletions

File tree

uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,8 @@ protected async Task<IEnumerable<uSyncChange>> DeserializePropertiesAsync(TObjec
422422
/// so we store them and do them once we've put
423423
/// things in.
424424
List<uSyncChange> changes = [];
425-
Dictionary<string, string> propertiesToMove = [];
425+
// value can be null - when the property is being moved out of all groups.
426+
Dictionary<string, string?> propertiesToMove = [];
426427

427428
List<string>? compositeProperties = default;
428429

@@ -567,6 +568,15 @@ protected async Task<IEnumerable<uSyncChange>> DeserializePropertiesAsync(TObjec
567568
changes.AddWarning(alias, name, $"Unable to find tab {tabAlias} to add property too");
568569
}
569570
}
571+
else
572+
{
573+
// no tab in the config - the property has been moved out of
574+
// any group. if it is currently in one, move it out (issue #1009)
575+
if (item.PropertyGroups.Any(x => x.PropertyTypes?.Contains(result.Property.Alias) is true))
576+
{
577+
propertiesToMove[result.Property.Alias] = null;
578+
}
579+
}
570580
}
571581
}
572582

@@ -1216,12 +1226,27 @@ private async Task<PropertyTypeResult> GetOrCreatePropertyAsync(TObject item,
12161226
return result;
12171227
}
12181228

1219-
private static IEnumerable<uSyncChange> MoveProperties(IContentTypeBase item, IDictionary<string, string> moves)
1229+
private static IEnumerable<uSyncChange> MoveProperties(IContentTypeBase item, IDictionary<string, string?> moves)
12201230
{
12211231
foreach (var move in moves)
12221232
{
1223-
item.MovePropertyType(move.Key, move.Value);
1224-
yield return uSyncChange.Update($"{move.Key}/Tab/{move.Value}", move.Key, "", move.Value);
1233+
if (move.Value is null)
1234+
{
1235+
// moving the property out of all groups. MovePropertyType(alias, null)
1236+
// removes it from its current group but does *not* re-add it to the
1237+
// 'no group' collection, leaving it orphaned - so the change does not
1238+
// stick until a second import. We re-home it explicitly. (issue #1009)
1239+
var property = item.PropertyTypes.FirstOrDefault(x => x.Alias.InvariantEquals(move.Key));
1240+
item.MovePropertyType(move.Key, null!);
1241+
if (property is not null && item.PropertyTypes.Any(x => x.Alias.InvariantEquals(move.Key)) is false)
1242+
item.AddPropertyType(property);
1243+
}
1244+
else
1245+
{
1246+
item.MovePropertyType(move.Key, move.Value);
1247+
}
1248+
1249+
yield return uSyncChange.Update($"{move.Key}/Tab/{move.Value}", move.Key, "", move.Value ?? "(No group)");
12251250
}
12261251
}
12271252

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Linq;
2+
3+
using NUnit.Framework;
4+
5+
using Umbraco.Cms.Core.Models;
6+
using Umbraco.Cms.Core.Strings;
7+
8+
namespace uSync.Tests.Serializers;
9+
10+
/// <summary>
11+
/// guards the behaviour the content type serializer relies on when a property
12+
/// is moved out of all groups (#1009).
13+
/// </summary>
14+
/// <remarks>
15+
/// Umbraco's <c>MovePropertyType(alias, null)</c> removes the property from its
16+
/// group but does NOT re-add it to the 'no group' collection - it orphans it
17+
/// (see <see cref="MoveToNull_OrphansTheProperty"/>). The serializer works
18+
/// around this by re-adding the property with <c>AddPropertyType</c>.
19+
/// </remarks>
20+
[TestFixture]
21+
public class MovePropertyTypeTests
22+
{
23+
private static IShortStringHelper ShortStringHelper
24+
=> new DefaultShortStringHelper(new DefaultShortStringHelperConfig());
25+
26+
private static IContentType BuildContentTypeWithGroupedProperty()
27+
{
28+
var contentType = new ContentType(ShortStringHelper, -1)
29+
{
30+
Alias = "test",
31+
Name = "Test"
32+
};
33+
34+
var propertyType = new PropertyType(ShortStringHelper, "Umbraco.TextBox", ValueStorageType.Nvarchar)
35+
{
36+
Alias = "prop1",
37+
Name = "Prop1"
38+
};
39+
40+
contentType.AddPropertyGroup("content", "Content");
41+
contentType.AddPropertyType(propertyType, "content", "Content");
42+
43+
return contentType;
44+
}
45+
46+
[Test]
47+
public void MoveToNull_OrphansTheProperty()
48+
{
49+
// documents the Umbraco behaviour the fix works around: moving a property
50+
// to a null group removes it from the group but loses it entirely.
51+
var item = BuildContentTypeWithGroupedProperty();
52+
53+
item.MovePropertyType("prop1", null);
54+
55+
Assert.Multiple(() =>
56+
{
57+
Assert.That(item.PropertyGroups["content"].PropertyTypes.Any(x => x.Alias == "prop1"), Is.False, "removed from group");
58+
Assert.That(item.PropertyTypes.Any(x => x.Alias == "prop1"), Is.False, "but also orphaned from the content type");
59+
});
60+
}
61+
62+
[Test]
63+
public void MoveToNull_ThenReAdd_LandsInNoGroup()
64+
{
65+
// the approach the serializer uses: move out, then re-home into no-group.
66+
var item = BuildContentTypeWithGroupedProperty();
67+
68+
var property = item.PropertyTypes.FirstOrDefault(x => x.Alias == "prop1");
69+
item.MovePropertyType("prop1", null);
70+
if (property is not null && item.PropertyTypes.Any(x => x.Alias == "prop1") is false)
71+
item.AddPropertyType(property);
72+
73+
Assert.Multiple(() =>
74+
{
75+
Assert.That(item.PropertyGroups["content"].PropertyTypes.Any(x => x.Alias == "prop1"), Is.False, "prop1 no longer in the group");
76+
Assert.That(item.PropertyTypes.Any(x => x.Alias == "prop1"), Is.True, "prop1 still exists on the content type");
77+
Assert.That(item.NoGroupPropertyTypes.Any(x => x.Alias == "prop1"), Is.True, "prop1 is in NoGroupPropertyTypes");
78+
});
79+
}
80+
}

0 commit comments

Comments
 (0)