Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cc6ee6d1 | ||
|
|
af04ff3803 | ||
|
|
d2fa6eeaef | ||
|
|
fffe68543d | ||
|
|
add9878f3e | ||
|
|
5e1d197356 | ||
|
|
28aea9fcf7 | ||
|
|
9f0f0fc951 | ||
|
|
1dcd0a316e | ||
|
|
d38f2f2eaa | ||
|
|
0cbc32ed43 | ||
|
|
ac728286e4 | ||
|
|
4423cf2f07 | ||
|
|
687674b532 | ||
|
|
f02432a3e6 | ||
|
|
9bc1ae530c | ||
|
|
757dea630f | ||
|
|
06bf1a6a13 | ||
|
|
3774b23516 | ||
|
|
a7b25899a3 | ||
|
|
3007efc358 | ||
|
|
1629ab2072 | ||
|
|
40b45be952 | ||
|
|
d487afe748 | ||
|
|
74fe96b1cb | ||
|
|
3dcf1aa028 | ||
|
|
efb20e93db | ||
|
|
e0d97b498b | ||
|
|
1c75897f1f | ||
|
|
7e06c59c62 | ||
|
|
e3265dbb9f | ||
|
|
a8f588d8d8 | ||
|
|
5fc8031052 | ||
|
|
29913bbab5 | ||
|
|
b8fdc1a7ad | ||
|
|
258377dcc0 | ||
|
|
efa627e3a6 | ||
|
|
c00cbca152 | ||
|
|
55f9c3b795 | ||
|
|
4a22245d2c | ||
|
|
0c9f19af5a | ||
|
|
8706f68720 | ||
|
|
d5c06f9eec | ||
|
|
2438ae54f2 | ||
|
|
a28ca1dfbb | ||
|
|
b847bb9d37 | ||
|
|
aaf558e4bb | ||
|
|
3f9fbc6f5e | ||
|
|
981b758d37 | ||
|
|
a5947c4a35 | ||
|
|
655c66b8e7 | ||
|
|
e41709504f | ||
|
|
4d78274f43 | ||
|
|
5b4d9f087a | ||
|
|
4855ea884e | ||
|
|
c716d27b1b | ||
|
|
63ad13d615 | ||
|
|
0189c02d49 | ||
|
|
5fa6f22457 | ||
|
|
c7eaf54e10 | ||
|
|
7eda9fd825 | ||
|
|
1e79a245af | ||
|
|
c4d604f82f | ||
|
|
d2fe0654b8 | ||
|
|
46aaad46bb | ||
|
|
786fb35e97 | ||
|
|
72d68acff4 | ||
|
|
5128568016 | ||
|
|
e0ab9afc68 | ||
|
|
979f21c476 | ||
|
|
dc82922139 | ||
|
|
254aac615c | ||
|
|
450af3fd13 | ||
|
|
91f6f8a0b7 | ||
|
|
a901550bca | ||
|
|
44fb63394e | ||
|
|
cfdfda6a97 | ||
|
|
1f19bcbfe5 | ||
|
|
ec410fabe1 | ||
|
|
520e4b7014 | ||
|
|
35174f6a2e | ||
|
|
e15cf06470 | ||
|
|
b07596c8cc | ||
|
|
a443a26203 | ||
|
|
3bb4d7ec5f |
@@ -9,6 +9,7 @@ namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
public class IndexingSettings
|
||||
{
|
||||
private const bool StaticExplicitlyIndexEachNestedProperty = false;
|
||||
private const bool StaticIndexExternalElements = false;
|
||||
private const int StaticBatchSize = 10000;
|
||||
|
||||
/// <summary>
|
||||
@@ -17,6 +18,12 @@ public class IndexingSettings
|
||||
[DefaultValue(StaticExplicitlyIndexEachNestedProperty)]
|
||||
public bool ExplicitlyIndexEachNestedProperty { get; set; } = StaticExplicitlyIndexEachNestedProperty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the content of external elements referenced by block editors is flattened into the index entry of referencing documents. Requires a rebuild of indexes when changed.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticIndexExternalElements)]
|
||||
public bool IndexExternalElements { get; set; } = StaticIndexExternalElements;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how many items to index at a time.
|
||||
/// </summary>
|
||||
|
||||
@@ -357,6 +357,16 @@ public static partial class Constants
|
||||
/// </summary>
|
||||
public const string RelatedElementAlias = "umbElement";
|
||||
|
||||
/// <summary>
|
||||
/// Name for default relation type "External Block Element".
|
||||
/// </summary>
|
||||
public const string RelatedExternalBlockElementName = "External Block Element";
|
||||
|
||||
/// <summary>
|
||||
/// Alias for default relation type "External Block Element".
|
||||
/// </summary>
|
||||
public const string RelatedExternalBlockElementAlias = "umbExternalBlockElement";
|
||||
|
||||
/// <summary>
|
||||
/// Name for default relation type "Relate Document On Copy".
|
||||
/// </summary>
|
||||
@@ -414,7 +424,7 @@ public static partial class Constants
|
||||
/// Developers should not manually use these relation types since they will all be cleared whenever an entity
|
||||
/// (content, media, member or element) is saved since they are auto-populated based on property values.
|
||||
/// </remarks>
|
||||
public static string[] AutomaticRelationTypes { get; } = { RelatedMediaAlias, RelatedMemberAlias, RelatedDocumentAlias, RelatedElementAlias };
|
||||
public static string[] AutomaticRelationTypes { get; } = { RelatedMediaAlias, RelatedMemberAlias, RelatedDocumentAlias, RelatedElementAlias, RelatedExternalBlockElementAlias };
|
||||
|
||||
// TODO: return a list of built in types so we can use that to prevent deletion in the UI
|
||||
}
|
||||
|
||||
@@ -22,4 +22,11 @@ public interface IDeferredSearchReindexService
|
||||
/// </summary>
|
||||
/// <param name="memberTypeIds">The member type IDs to reindex.</param>
|
||||
void QueueMemberTypeReindex(IReadOnlyCollection<int> memberTypeIds);
|
||||
|
||||
/// <summary>
|
||||
/// Queues a set of element node ids whose change requires re-indexing the documents that
|
||||
/// (transitively) embed them via block editors.
|
||||
/// </summary>
|
||||
/// <param name="elementIds">The element node ids that changed.</param>
|
||||
void QueueElementReindex(IReadOnlyCollection<int> elementIds);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Infrastructure.Examine;
|
||||
@@ -86,6 +86,8 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ContentCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<ContentTypeCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<PublicAccessCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<ElementSavedNotification, ElementIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<ElementPublishedNotification, ElementIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<MediaCacheRefresherNotification, MediaIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<MemberCacheRefresherNotification, MemberIndexingNotificationHandler>();
|
||||
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
|
||||
|
||||
@@ -2834,6 +2834,14 @@ internal sealed class DatabaseDataCreator
|
||||
Constants.ObjectTypes.ElementContainer,
|
||||
false,
|
||||
false);
|
||||
CreateRelationTypeData(
|
||||
10,
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
true);
|
||||
}
|
||||
|
||||
private void CreateRelationTypeData(
|
||||
|
||||
@@ -103,6 +103,9 @@ public partial class UmbracoPlan : MigrationPlan
|
||||
To<V_18_0_0.AddElementContainerPermissions>("{D00BB11A-DDF8-47C4-B58E-150C123BB3BB}");
|
||||
To<V_18_0_0.MigrateSingleBlockList>("{74332C49-B279-4945-8943-F8F00B1F5949}");
|
||||
To<V_18_0_0.AddElementSectionForAdmins>("{6FE4656E-8B8D-452F-AE2A-438A615B61BC}");
|
||||
|
||||
// To 19.0.0
|
||||
To<V_19_0_0.AddExternalBlockElementRelationType>("{2D8F1B6E-4C3A-4E7D-9A1B-5F0C7E2D8A93}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Install;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_19_0_0;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the "External Block Element" relation type used to track elements that are
|
||||
/// embedded as external (reusable) block content, so that only documents whose index
|
||||
/// includes the element's content are reindexed when the element changes.
|
||||
/// </summary>
|
||||
public class AddExternalBlockElementRelationType : AsyncMigrationBase
|
||||
{
|
||||
private readonly IRelationService _relationService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AddExternalBlockElementRelationType"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The migration context.</param>
|
||||
/// <param name="relationService">The relation service used to create the relation type.</param>
|
||||
public AddExternalBlockElementRelationType(IMigrationContext context, IRelationService relationService)
|
||||
: base(context)
|
||||
=> _relationService = relationService;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task MigrateAsync()
|
||||
{
|
||||
IRelationType? relationType = _relationService.GetRelationTypeByAlias(
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias);
|
||||
if (relationType != null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Generate the same unique key a fresh install would produce.
|
||||
Guid key = DatabaseDataCreator.CreateUniqueRelationTypeId(
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName);
|
||||
|
||||
// Save via the service so the repository cache is updated as well.
|
||||
relationType = new RelationType(
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName,
|
||||
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
|
||||
false,
|
||||
parentObjectType: null,
|
||||
childObjectType: null,
|
||||
isDependency: true)
|
||||
{
|
||||
Key = key
|
||||
};
|
||||
_relationService.Save(relationType);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -126,7 +126,7 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
|
||||
private void ParseKeys(JsonArray contentData, JsonArray settingsData, JsonObject layoutData)
|
||||
{
|
||||
// recurse a JSON object to find all contained block editor layouts
|
||||
List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
|
||||
static List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
|
||||
{
|
||||
var layoutItems = new List<JsonObject>();
|
||||
if (jsonObject.ContainsKey("key") && jsonObject.ContainsKey("contentKey"))
|
||||
@@ -151,7 +151,7 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
|
||||
// grab keys applicable for replacement from all the layouts - that is:
|
||||
// - the key of the layout itself ("key").
|
||||
// - the key of the content item ("contentKey").
|
||||
// - ONLY for local content; do NOT replace content item keys for shared content.
|
||||
// - ONLY for local content; do NOT replace content item keys for external content.
|
||||
// - the key of the settings item ("settingsKey") if present.
|
||||
List<JsonObject> layoutItems = GetLayoutItemsRecursively(layoutData);
|
||||
var keys = layoutItems.SelectMany(layoutItem => new[]
|
||||
|
||||
+64
-48
@@ -14,6 +14,7 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditorCollection;
|
||||
private readonly IElementService _elementService;
|
||||
private readonly IOptionsMonitor<IndexingSettings> _indexingSettings;
|
||||
|
||||
protected BlockValuePropertyIndexValueFactoryBase(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
@@ -24,8 +25,10 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
|
||||
{
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_elementService = elementService;
|
||||
_indexingSettings = indexingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IEnumerable<IndexValue> Handle(
|
||||
TSerialized deserializedPropertyValue,
|
||||
IProperty property,
|
||||
@@ -108,60 +111,76 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
|
||||
protected abstract IEnumerable<RawDataItem> GetDataItems(TSerialized input, bool published);
|
||||
|
||||
/// <summary>
|
||||
/// Unwraps block item data as data items.
|
||||
/// Unwraps block item data as data items, in layout order.
|
||||
/// </summary>
|
||||
protected IEnumerable<RawDataItem> GetDataItems(IEnumerable<IBlockLayoutItem> layouts, IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
|
||||
{
|
||||
List<RawDataItem> indexData;
|
||||
if (published is false)
|
||||
{
|
||||
indexData = contentData.Select(ToRawData).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
indexData = new();
|
||||
foreach (BlockItemData blockItemData in contentData)
|
||||
{
|
||||
var exposedCultures = expose
|
||||
.Where(e => e.ContentKey == blockItemData.Key)
|
||||
.Select(e => e.Culture)
|
||||
.ToArray();
|
||||
|
||||
if (exposedCultures.Any() is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exposedCultures.Contains(null)
|
||||
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
|
||||
{
|
||||
indexData.Add(ToRawData(blockItemData));
|
||||
continue;
|
||||
}
|
||||
|
||||
indexData.Add(
|
||||
ToRawData(
|
||||
blockItemData.ContentTypeKey,
|
||||
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
|
||||
}
|
||||
}
|
||||
|
||||
IBlockLayoutItem[] layoutsAsArray = layouts as IBlockLayoutItem[] ?? layouts.ToArray();
|
||||
|
||||
// Get the shared element keys from all layouts.
|
||||
// NOTE: While the Grid areas are modeled to contain areas within areas, in reality it cannot be configured as
|
||||
// such, so this "top-level aggregation" of shared content keys works in effect.
|
||||
Guid[] sharedElementKeys = layoutsAsArray
|
||||
// such, so this "top-level aggregation" of layout items works in effect.
|
||||
IBlockLayoutItem[] allLayouts = layoutsAsArray
|
||||
.Union(layoutsAsArray.SelectMany(l => l.GetContainedLayouts()))
|
||||
.Where(l => l.IsExternalContent)
|
||||
.Select(l => l.ContentKey)
|
||||
.ToArray();
|
||||
|
||||
if (sharedElementKeys.Length > 0)
|
||||
var contentDataByKey = contentData.ToDictionary(d => d.Key);
|
||||
|
||||
Dictionary<Guid, RawDataItem>? externalDataByKey = _indexingSettings.CurrentValue.IndexExternalElements
|
||||
? GetExternalElementDataItems(allLayouts, published)
|
||||
: null;
|
||||
foreach (IBlockLayoutItem layout in allLayouts)
|
||||
{
|
||||
IEnumerable<IElement> elements = _elementService.GetByIds(sharedElementKeys);
|
||||
indexData.AddRange(
|
||||
elements.Select(element => new RawDataItem
|
||||
if (layout.IsExternalContent)
|
||||
{
|
||||
if (externalDataByKey?.TryGetValue(layout.ContentKey, out RawDataItem? elementData) == true)
|
||||
{
|
||||
yield return elementData;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!contentDataByKey.TryGetValue(layout.ContentKey, out BlockItemData? blockItemData))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (published is false)
|
||||
{
|
||||
yield return ToRawData(blockItemData);
|
||||
continue;
|
||||
}
|
||||
|
||||
var exposedCultures = expose
|
||||
.Where(e => e.ContentKey == blockItemData.Key)
|
||||
.Select(e => e.Culture)
|
||||
.ToArray();
|
||||
|
||||
if (exposedCultures.Any() is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exposedCultures.Contains(null)
|
||||
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
|
||||
{
|
||||
yield return ToRawData(blockItemData);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return ToRawData(
|
||||
blockItemData.ContentTypeKey,
|
||||
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture)));
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<Guid, RawDataItem> GetExternalElementDataItems(IBlockLayoutItem[] allLayouts, bool published)
|
||||
{
|
||||
Guid[] externalKeys = allLayouts.Where(l => l.IsExternalContent).Select(l => l.ContentKey).ToArray();
|
||||
return _elementService.GetByIds(externalKeys)
|
||||
.ToDictionary(
|
||||
element => element.Key,
|
||||
element => new RawDataItem
|
||||
{
|
||||
ContentTypeKey = element.ContentType.Key,
|
||||
Properties = element
|
||||
@@ -177,10 +196,7 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
|
||||
: value.EditedValue,
|
||||
}))
|
||||
.ToArray(),
|
||||
}));
|
||||
}
|
||||
|
||||
return indexData;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -23,7 +24,6 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactoryCollection;
|
||||
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
|
||||
private BlockEditorValues<TValue, TLayout>? _blockEditorValues;
|
||||
private readonly ILanguageService _languageService;
|
||||
|
||||
protected BlockValuePropertyValueEditorBase(
|
||||
@@ -46,15 +46,15 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
_languageService = languageService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
|
||||
/// optimising subsequent retrieval of entities when parsing and converting property values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method iterates through all property values associated with data editors in the provided
|
||||
/// block editor data and invokes caching for referenced entities where supported by the property editor.
|
||||
/// </remarks>
|
||||
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
|
||||
/// <summary>
|
||||
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
|
||||
/// optimising subsequent retrieval of entities when parsing and converting property values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method iterates through all property values associated with data editors in the provided
|
||||
/// block editor data and invokes caching for referenced entities where supported by the property editor.
|
||||
/// </remarks>
|
||||
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
@@ -92,8 +92,8 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
|
||||
protected BlockEditorValues<TValue, TLayout> BlockEditorValues
|
||||
{
|
||||
get => _blockEditorValues ?? throw new NullReferenceException($"The property {nameof(BlockEditorValues)} must be initialized at value editor construction");
|
||||
set => _blockEditorValues = value;
|
||||
get => field ?? throw new NullReferenceException($"The property {nameof(BlockEditorValues)} must be initialized at value editor construction");
|
||||
set;
|
||||
}
|
||||
|
||||
protected IEnumerable<UmbracoEntityReference> GetBlockValueReferences(TValue blockValue)
|
||||
@@ -131,6 +131,16 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<IBlockLayoutItem> allExternalLayoutItems = blockValue.Layout.Values
|
||||
.SelectMany(layouts => layouts)
|
||||
.Union(blockValue.Layout.Values.SelectMany(layouts => layouts.SelectMany(l => l.GetContainedLayouts())))
|
||||
.Where(l => l.IsExternalContent);
|
||||
|
||||
foreach (IBlockLayoutItem layout in allExternalLayoutItems)
|
||||
{
|
||||
result.Add(new UmbracoEntityReference(new GuidUdi(Constants.UdiEntityType.Element, layout.ContentKey)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -225,10 +235,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value);
|
||||
|
||||
// Update the raw value since this is what will get serialized out.
|
||||
if (editedValue != null)
|
||||
{
|
||||
editedValue.Value = newValue;
|
||||
}
|
||||
editedValue?.Value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,11 +340,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
item.Values = _blockEditorVarianceHandler.AlignPropertyVarianceAsync(item.Values, culture).GetAwaiter().GetResult();
|
||||
foreach (BlockPropertyValue blockPropertyValue in item.Values)
|
||||
{
|
||||
IPropertyType? propertyType = blockPropertyValue.PropertyType;
|
||||
if (propertyType is null)
|
||||
{
|
||||
throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(items));
|
||||
}
|
||||
IPropertyType? propertyType = blockPropertyValue.PropertyType ?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(items));
|
||||
|
||||
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
|
||||
if (propertyEditor is null)
|
||||
@@ -422,7 +425,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
bool canUpdateInvariantData,
|
||||
HashSet<string> allowedCultures)
|
||||
{
|
||||
var mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
|
||||
BlockEditorData<TValue, TLayout>? mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
|
||||
|
||||
// if the structure (invariant) is not defined after merger, the target content does not matter
|
||||
if (mergedInvariant?.Layout is null)
|
||||
@@ -432,10 +435,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
|
||||
// since we merged the invariant data (layout) before we get to this point
|
||||
// we just need an empty valid object to run comparisons at this point
|
||||
if (source is null)
|
||||
{
|
||||
source = new BlockEditorData<TValue, TLayout>([], new TValue());
|
||||
}
|
||||
source ??= new BlockEditorData<TValue, TLayout>([], new TValue());
|
||||
|
||||
// update the target with the merged invariant
|
||||
target!.BlockValue.Layout = mergedInvariant.BlockValue.Layout;
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
@@ -5,11 +5,13 @@ using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Search;
|
||||
|
||||
@@ -34,9 +36,11 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
private readonly ICoreScopeProvider _scopeProvider;
|
||||
private readonly ILogger<DeferredSearchReindexService> _logger;
|
||||
private readonly CancellationTokenSource _shutdownCts;
|
||||
private readonly IRelationService _relationService;
|
||||
private readonly ConcurrentDictionary<int, byte> _pendingContentTypeIds = new();
|
||||
private readonly ConcurrentDictionary<int, byte> _pendingMediaTypeIds = new();
|
||||
private readonly ConcurrentDictionary<int, byte> _pendingMemberTypeIds = new();
|
||||
private readonly ConcurrentDictionary<int, byte> _pendingElementIds = new();
|
||||
private int _processing; // 0 = idle, 1 = active
|
||||
|
||||
/// <summary>
|
||||
@@ -51,6 +55,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
/// <param name="scopeProvider">The scope provider, used to create scopes for repository access.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="hostApplicationLifetime">The application lifetime, used to cancel in-flight reindexing on shutdown.</param>
|
||||
/// <param name="relationService">The relation service, used to traverse element-to-document relations.</param>
|
||||
public DeferredSearchReindexService(
|
||||
IDocumentRepository documentRepository,
|
||||
IMediaRepository mediaRepository,
|
||||
@@ -60,7 +65,8 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
IOptionsMonitor<IndexingSettings> indexingSettings,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
ILogger<DeferredSearchReindexService> logger,
|
||||
IHostApplicationLifetime hostApplicationLifetime)
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
IRelationService relationService)
|
||||
{
|
||||
_documentRepository = documentRepository;
|
||||
_mediaRepository = mediaRepository;
|
||||
@@ -71,6 +77,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
_scopeProvider = scopeProvider;
|
||||
_logger = logger;
|
||||
_shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(hostApplicationLifetime.ApplicationStopping);
|
||||
_relationService = relationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -106,6 +113,17 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
ScheduleProcessing();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void QueueElementReindex(IReadOnlyCollection<int> elementIds)
|
||||
{
|
||||
foreach (var id in elementIds)
|
||||
{
|
||||
_pendingElementIds.TryAdd(id, 0);
|
||||
}
|
||||
|
||||
ScheduleProcessing();
|
||||
}
|
||||
|
||||
private void ScheduleProcessing()
|
||||
{
|
||||
if (_shutdownCts.IsCancellationRequested)
|
||||
@@ -140,6 +158,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
var contentTypeIds = DrainIds(_pendingContentTypeIds);
|
||||
var mediaTypeIds = DrainIds(_pendingMediaTypeIds);
|
||||
var memberTypeIds = DrainIds(_pendingMemberTypeIds);
|
||||
var elementIds = DrainIds(_pendingElementIds);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -164,6 +183,13 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
_logger.LogInformation("Deferred reindex completed for member type IDs: {MemberTypeIds}", memberTypeIds);
|
||||
}
|
||||
|
||||
if (elementIds.Length > 0)
|
||||
{
|
||||
_logger.LogInformation("Deferred reindex starting for documents referencing element IDs: {ElementIds}", elementIds);
|
||||
ReindexDocumentsReferencingElements(elementIds);
|
||||
_logger.LogInformation("Deferred reindex completed for documents referencing element IDs: {ElementIds}", elementIds);
|
||||
}
|
||||
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -171,6 +197,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
RequeueIds(_pendingContentTypeIds, contentTypeIds);
|
||||
RequeueIds(_pendingMediaTypeIds, mediaTypeIds);
|
||||
RequeueIds(_pendingMemberTypeIds, memberTypeIds);
|
||||
RequeueIds(_pendingElementIds, elementIds);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -180,6 +207,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
RequeueIds(_pendingContentTypeIds, contentTypeIds);
|
||||
RequeueIds(_pendingMediaTypeIds, mediaTypeIds);
|
||||
RequeueIds(_pendingMemberTypeIds, memberTypeIds);
|
||||
RequeueIds(_pendingElementIds, elementIds);
|
||||
|
||||
if (consecutiveFailures >= MaxConsecutiveFailures)
|
||||
{
|
||||
@@ -263,6 +291,86 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
c => _umbracoIndexingHandler.ReIndexForMember(c));
|
||||
}
|
||||
|
||||
private void ReindexDocumentsReferencingElements(int[] elementIds)
|
||||
{
|
||||
IReadOnlyCollection<int> documentIds = FindDocumentIdsReferencingElements(elementIds);
|
||||
if (documentIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var publishChecked = new Dictionary<int, bool>();
|
||||
foreach (IEnumerable<int> batch in documentIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
var batchIds = batch.ToArray();
|
||||
IContent[] documents;
|
||||
using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
documents = _documentRepository.GetMany(batchIds).ToArray();
|
||||
}
|
||||
|
||||
foreach (IContent document in documents)
|
||||
{
|
||||
var isPublished = false;
|
||||
if (document.Published && publishChecked.TryGetValue(document.Id, out isPublished) is false)
|
||||
{
|
||||
isPublished = _publishStatusQueryService.HasPublishedAncestorPath(document.Key);
|
||||
publishChecked[document.Id] = isPublished;
|
||||
}
|
||||
|
||||
_umbracoIndexingHandler.ReIndexForContent(document, isPublished);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlyCollection<int> FindDocumentIdsReferencingElements(IEnumerable<int> elementIds)
|
||||
{
|
||||
var visitedElementIds = new HashSet<int>();
|
||||
var documentIds = new HashSet<int>();
|
||||
var queue = new Queue<int>(elementIds);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var elementId = queue.Dequeue();
|
||||
if (visitedElementIds.Add(elementId) is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (IUmbracoEntity documentParent in GetParentEntities(elementId, UmbracoObjectTypes.Document))
|
||||
{
|
||||
documentIds.Add(documentParent.Id);
|
||||
}
|
||||
|
||||
foreach (IUmbracoEntity elementParent in GetParentEntities(elementId, UmbracoObjectTypes.Element))
|
||||
{
|
||||
if (visitedElementIds.Contains(elementParent.Id) is false)
|
||||
{
|
||||
queue.Enqueue(elementParent.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return documentIds;
|
||||
}
|
||||
|
||||
private IEnumerable<IUmbracoEntity> GetParentEntities(int childId, UmbracoObjectTypes objectType)
|
||||
{
|
||||
var results = new List<IUmbracoEntity>();
|
||||
var pageSize = _indexingSettings.CurrentValue.BatchSize;
|
||||
long page = 0;
|
||||
var total = long.MaxValue;
|
||||
while (page * pageSize < total)
|
||||
{
|
||||
IUmbracoEntity[] items = _relationService
|
||||
.GetPagedParentEntitiesByChildId(childId, page++, pageSize, out total, objectType)
|
||||
.ToArray();
|
||||
results.AddRange(items);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pages through a repository without acquiring distributed locks and invokes an action for each item.
|
||||
/// </summary>
|
||||
@@ -328,7 +436,8 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
|
||||
private bool HasPendingIds() =>
|
||||
_pendingContentTypeIds.IsEmpty is false ||
|
||||
_pendingMediaTypeIds.IsEmpty is false ||
|
||||
_pendingMemberTypeIds.IsEmpty is false;
|
||||
_pendingMemberTypeIds.IsEmpty is false ||
|
||||
_pendingElementIds.IsEmpty is false;
|
||||
|
||||
private static int[] DrainIds(ConcurrentDictionary<int, byte> dictionary)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Queues reindexing of documents that reference elements when those elements are saved or published.
|
||||
/// </summary>
|
||||
internal sealed class ElementIndexingNotificationHandler :
|
||||
INotificationHandler<ElementSavedNotification>,
|
||||
INotificationHandler<ElementPublishedNotification>
|
||||
{
|
||||
private readonly IDeferredSearchReindexService _deferredSearchReindexService;
|
||||
|
||||
public ElementIndexingNotificationHandler(IDeferredSearchReindexService deferredSearchReindexService)
|
||||
=> _deferredSearchReindexService = deferredSearchReindexService;
|
||||
|
||||
public void Handle(ElementSavedNotification notification)
|
||||
=> QueueElementIds(notification.SavedEntities);
|
||||
|
||||
public void Handle(ElementPublishedNotification notification)
|
||||
=> QueueElementIds(notification.PublishedEntities);
|
||||
|
||||
private void QueueElementIds(IEnumerable<IElement> elements)
|
||||
{
|
||||
var ids = elements.Select(e => e.Id).ToArray();
|
||||
if (ids.Length > 0)
|
||||
{
|
||||
_deferredSearchReindexService.QueueElementReindex(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -135,7 +135,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
|
||||
@state()
|
||||
private _exposed?: boolean;
|
||||
|
||||
private _localExpose?: boolean;
|
||||
private _hasExpose?: boolean;
|
||||
|
||||
// Unsupported is triggered if the Block Type is not recognized, it can also be triggered by the Content Element Type not existing any longer. [NL]
|
||||
@state()
|
||||
@@ -243,7 +243,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
|
||||
this.observe(
|
||||
this.#context.hasExpose,
|
||||
(exposed) => {
|
||||
this._localExpose = exposed;
|
||||
this._hasExpose = exposed;
|
||||
this.#updateExposedState();
|
||||
},
|
||||
null,
|
||||
@@ -450,7 +450,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
|
||||
const isExposed = this._isExternalContent
|
||||
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
|
||||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
|
||||
: this._localExpose;
|
||||
: this._hasExpose;
|
||||
this.#updateBlockViewProps({ unpublished: !isExposed });
|
||||
this._exposed = isExposed;
|
||||
}
|
||||
|
||||
+3
-3
@@ -118,7 +118,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
|
||||
@state()
|
||||
private _exposed?: boolean;
|
||||
|
||||
private _localExpose?: boolean;
|
||||
private _hasExpose?: boolean;
|
||||
|
||||
@state()
|
||||
private _unsupported?: boolean;
|
||||
@@ -203,7 +203,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
|
||||
this.observe(
|
||||
this.#context.hasExpose,
|
||||
(exposed) => {
|
||||
this._localExpose = exposed;
|
||||
this._hasExpose = exposed;
|
||||
this.#updateExposedState();
|
||||
},
|
||||
null,
|
||||
@@ -353,7 +353,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
|
||||
const isExposed = this._isExternalContent
|
||||
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
|
||||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
|
||||
: this._localExpose;
|
||||
: this._hasExpose;
|
||||
this.#updateBlockViewProps({ unpublished: !isExposed });
|
||||
this._exposed = isExposed;
|
||||
}
|
||||
|
||||
+3
-3
@@ -119,7 +119,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
|
||||
@state()
|
||||
private _exposed?: boolean;
|
||||
|
||||
private _localExpose?: boolean;
|
||||
private _hasExpose?: boolean;
|
||||
|
||||
@state()
|
||||
private _unsupported?: boolean;
|
||||
@@ -201,7 +201,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
|
||||
this.observe(
|
||||
this.#context.hasExpose,
|
||||
(exposed) => {
|
||||
this._localExpose = exposed;
|
||||
this._hasExpose = exposed;
|
||||
this.#updateExposedState();
|
||||
},
|
||||
null,
|
||||
@@ -320,7 +320,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
|
||||
const isExposed = this._isExternalContent
|
||||
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
|
||||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
|
||||
: this._localExpose;
|
||||
: this._hasExpose;
|
||||
this.#updateBlockViewProps({ unpublished: !isExposed });
|
||||
this._exposed = isExposed;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface UmbBlockAction<ArgsMetaType> extends UmbAction<UmbBlockActionAr
|
||||
* When provided, the default kind element subscribes to it and updates the link reactively,
|
||||
* rather than resolving `getHref()` once at initialisation time.
|
||||
*/
|
||||
href?: Observable<string | undefined>;
|
||||
hrefObservable?: Observable<string | undefined>;
|
||||
|
||||
/**
|
||||
* The `execute` method, the action will act as a button.
|
||||
@@ -37,5 +37,5 @@ export interface UmbBlockAction<ArgsMetaType> extends UmbAction<UmbBlockActionAr
|
||||
* state controller reactively, rather than resolving `getValidationDataPath()` once at
|
||||
* initialisation time.
|
||||
*/
|
||||
validationDataPath?: Observable<string | undefined>;
|
||||
validationDataPathObservable?: Observable<string | undefined>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
|
||||
export class UmbDisconnectFromElementLibraryBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
|
||||
override async execute() {
|
||||
const context = await this.getContext(UMB_BLOCK_ENTRY_CONTEXT);
|
||||
await context?.requestDisconnectFromExternalContent();
|
||||
await context?.requestDisconnectFromElementLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-7
@@ -6,16 +6,23 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
|
||||
|
||||
/** Block action that navigates to the block's content editor workspace. */
|
||||
/**
|
||||
* Block action that navigates to the block's content editor workspace.
|
||||
* Exposes the workspace edit path via `getHref()` / `hrefObservable` and the content validation
|
||||
* data path via `getValidationDataPath()` / `validationDataPathObservable`.
|
||||
* The observable variants update reactively (e.g. after disconnect from Element Library),
|
||||
* whereas the promise variants resolve once for consumers that call them imperatively.
|
||||
*/
|
||||
export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
|
||||
#context?: typeof UMB_BLOCK_ENTRY_CONTEXT.TYPE;
|
||||
#contextReady: Promise<void>;
|
||||
#resolveContext!: () => void;
|
||||
|
||||
readonly #href = new UmbStringState(undefined);
|
||||
readonly href = this.#href.asObservable();
|
||||
readonly hrefObservable = this.#href.asObservable();
|
||||
|
||||
readonly #validationDataPath = new UmbStringState(undefined);
|
||||
readonly validationDataPath = this.#validationDataPath.asObservable();
|
||||
readonly validationDataPathObservable = this.#validationDataPath.asObservable();
|
||||
|
||||
constructor(host: UmbControllerHost, args: UmbBlockActionArgs<MetaBlockActionDefaultKind>) {
|
||||
super(host, args);
|
||||
@@ -25,16 +32,23 @@ export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActio
|
||||
});
|
||||
|
||||
this.consumeContext(UMB_BLOCK_ENTRY_CONTEXT, (context) => {
|
||||
this.#context = context;
|
||||
if (!context) return;
|
||||
this.#resolveContext();
|
||||
|
||||
this.observe(context.workspaceEditContentPath, (path) => this.#href.setValue(path || undefined), 'observeHref');
|
||||
this.observe(
|
||||
context.workspaceEditContentPath,
|
||||
(path) => this.#href.setValue(path || undefined),
|
||||
'observeHref',
|
||||
);
|
||||
|
||||
this.observe(
|
||||
context.contentKey,
|
||||
(contentKey) => {
|
||||
this.#validationDataPath.setValue(
|
||||
contentKey ? `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]` : undefined,
|
||||
contentKey
|
||||
? `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]`
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
'observeValidationDataPath',
|
||||
@@ -44,12 +58,15 @@ export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActio
|
||||
|
||||
override async getHref() {
|
||||
await this.#contextReady;
|
||||
return (await this.observe(this.href)?.asPromise()) || undefined;
|
||||
const path = await this.observe(this.#context?.workspaceEditContentPath)?.asPromise();
|
||||
return path || undefined;
|
||||
}
|
||||
|
||||
override async getValidationDataPath() {
|
||||
await this.#contextReady;
|
||||
return await this.observe(this.validationDataPath)?.asPromise();
|
||||
const contentKey = await this.observe(this.#context?.contentKey)?.asPromise();
|
||||
if (!contentKey) return undefined;
|
||||
return `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-5
@@ -6,16 +6,23 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
|
||||
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
/** Block action that navigates to the block's settings editor workspace. */
|
||||
/**
|
||||
* Block action that navigates to the block's settings editor workspace.
|
||||
* Exposes the workspace edit path via `getHref()` / `hrefObservable` and the settings validation
|
||||
* data path via `getValidationDataPath()` / `validationDataPathObservable`.
|
||||
* The observable variants update reactively (e.g. after transfer to Element Library),
|
||||
* whereas the promise variants resolve once for consumers that call them imperatively.
|
||||
*/
|
||||
export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
|
||||
#context?: typeof UMB_BLOCK_ENTRY_CONTEXT.TYPE;
|
||||
#contextReady: Promise<void>;
|
||||
#resolveContext!: () => void;
|
||||
|
||||
readonly #href = new UmbStringState(undefined);
|
||||
readonly href = this.#href.asObservable();
|
||||
readonly hrefObservable = this.#href.asObservable();
|
||||
|
||||
readonly #validationDataPath = new UmbStringState(undefined);
|
||||
readonly validationDataPath = this.#validationDataPath.asObservable();
|
||||
readonly validationDataPathObservable = this.#validationDataPath.asObservable();
|
||||
|
||||
constructor(host: UmbControllerHost, args: UmbBlockActionArgs<MetaBlockActionDefaultKind>) {
|
||||
super(host, args);
|
||||
@@ -25,6 +32,7 @@ export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActi
|
||||
});
|
||||
|
||||
this.consumeContext(UMB_BLOCK_ENTRY_CONTEXT, (context) => {
|
||||
this.#context = context;
|
||||
if (!context) return;
|
||||
this.#resolveContext();
|
||||
|
||||
@@ -44,12 +52,15 @@ export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActi
|
||||
|
||||
override async getHref() {
|
||||
await this.#contextReady;
|
||||
return (await this.observe(this.href)?.asPromise()) || undefined;
|
||||
const path = await this.observe(this.#context?.workspaceEditSettingsPath)?.asPromise();
|
||||
return path || undefined;
|
||||
}
|
||||
|
||||
override async getValidationDataPath() {
|
||||
await this.#contextReady;
|
||||
return await this.observe(this.validationDataPath)?.asPromise();
|
||||
const settingsKey = await this.observe(this.#context?.settingsKey)?.asPromise();
|
||||
if (!settingsKey) return undefined;
|
||||
return `$.settingsData[${UmbDataPathBlockElementDataQuery({ key: settingsKey })}]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
|
||||
export class UmbTransferToElementLibraryBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
|
||||
override async execute() {
|
||||
const context = await this.getContext(UMB_BLOCK_ENTRY_CONTEXT);
|
||||
await context?.requestTransferToExternalContent();
|
||||
await context?.requestTransferToElementLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -29,8 +29,8 @@ export class UmbBlockActionDefaultElement<
|
||||
this.#api = api;
|
||||
this._href = undefined;
|
||||
|
||||
if (api?.href) {
|
||||
this.observe(api.href, (href) => (this._href = href), 'observeHref');
|
||||
if (api?.hrefObservable) {
|
||||
this.observe(api.hrefObservable, (href) => (this._href = href), 'observeHref');
|
||||
} else {
|
||||
this.removeUmbControllerByAlias('observeHref');
|
||||
api?.getHref?.().then((href) => {
|
||||
@@ -38,9 +38,9 @@ export class UmbBlockActionDefaultElement<
|
||||
});
|
||||
}
|
||||
|
||||
if (api?.validationDataPath) {
|
||||
if (api?.validationDataPathObservable) {
|
||||
this.observe(
|
||||
api.validationDataPath,
|
||||
api.validationDataPathObservable,
|
||||
(path) => {
|
||||
this.removeUmbControllerByAlias('observeValidation');
|
||||
if (path) {
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-a
|
||||
export type BlockWorkspaceHasSettingsConditionConfig =
|
||||
UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasSettings'>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type
|
||||
export interface BlockWorkspaceHasContentConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasContent'> {}
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type BlockWorkspaceHasContentConditionConfig =
|
||||
UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasContent'>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export interface BlockEntryShowContentEditConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryShowContentEdit'> {
|
||||
@@ -22,8 +23,8 @@ export interface BlockWorkspaceIsReadOnlyConditionConfig extends UmbConditionCon
|
||||
match?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type
|
||||
export interface BlockEntryHasSettingsConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryHasSettings'> {}
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type BlockEntryHasSettingsConditionConfig = UmbConditionConfigBase<'Umb.Condition.BlockEntryHasSettings'>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export interface BlockEntryHasExternalContentConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryHasExternalContent'> {
|
||||
|
||||
@@ -58,14 +58,17 @@ export abstract class UmbBlockEntryContext<
|
||||
#contentKey?: string;
|
||||
#unsupported = new UmbBooleanState(undefined);
|
||||
readonly unsupported = this.#unsupported.asObservable();
|
||||
/** True when unsupported was set by a structural fault (missing block type or element type structure). Prevents the content observer from resetting the flag. */
|
||||
#structurallyUnsupported = false;
|
||||
|
||||
protected readonly localize = new UmbLocalizationController(this);
|
||||
|
||||
#isExternalContent = new UmbBooleanState(false);
|
||||
/** Observable that emits true when this block's content is shared (referenced from the Element Library) rather than local. */
|
||||
readonly isExternalContent = this.#isExternalContent.asObservable();
|
||||
|
||||
#externalContentVariantState = new UmbStringState(undefined);
|
||||
/** Observable of the shared element's variant state (e.g. 'Published', 'Draft'), resolved for the active culture/segment. */
|
||||
readonly externalContentVariantState = this.#externalContentVariantState.asObservable();
|
||||
|
||||
#pathAddendum = new UmbRoutePathAddendumContext(this);
|
||||
@@ -379,6 +382,10 @@ export abstract class UmbBlockEntryContext<
|
||||
null,
|
||||
);
|
||||
|
||||
// Re-observe content and expose when the layout's contentKey changes (e.g., after Transfer to
|
||||
// Element Library assigns a new element UUID, or Disconnect assigns a fresh local content key).
|
||||
// NOTE: this.#contentKey is updated by #observeLayout() AFTER _layout.setValue() emits, so we
|
||||
// must sync it here before calling the observe methods which read it.
|
||||
this.observe(
|
||||
this.contentKey,
|
||||
(contentKey) => {
|
||||
@@ -581,7 +588,7 @@ export abstract class UmbBlockEntryContext<
|
||||
);
|
||||
|
||||
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
|
||||
.addAdditionalPath('element')
|
||||
.addAdditionalPath('library')
|
||||
.addUniquePaths(['unique'])
|
||||
.onSetup(() => {
|
||||
return {
|
||||
@@ -620,7 +627,7 @@ export abstract class UmbBlockEntryContext<
|
||||
|
||||
// Observe the variant state of external content (published, draft, etc.)
|
||||
this.observe(
|
||||
this._manager.externalContentStateOf(contentKey),
|
||||
this._manager.elementStateOf(contentKey),
|
||||
(state) => {
|
||||
this.#externalContentVariantState.setValue(state ?? undefined);
|
||||
},
|
||||
@@ -834,15 +841,15 @@ export abstract class UmbBlockEntryContext<
|
||||
this.delete();
|
||||
}
|
||||
|
||||
async requestTransferToExternalContent() {
|
||||
async requestTransferToElementLibrary() {
|
||||
if (!this.#key) return;
|
||||
const name = this.getName();
|
||||
await this._manager?.requestTransferToExternalContent(this.#key, name);
|
||||
await this._manager?.requestTransferToElementLibrary(this.#key, name);
|
||||
}
|
||||
|
||||
async requestDisconnectFromExternalContent() {
|
||||
async requestDisconnectFromElementLibrary() {
|
||||
if (!this.#key) return;
|
||||
await this._manager?.requestDisconnectFromExternalContent(this.#key);
|
||||
await this._manager?.requestDisconnectFromElementLibrary(this.#key);
|
||||
}
|
||||
|
||||
public delete() {
|
||||
|
||||
+63
-55
@@ -83,8 +83,8 @@ export abstract class UmbBlockManagerContext<
|
||||
readonly #contents = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
|
||||
public readonly contents = this.#contents.asObservable();
|
||||
|
||||
readonly #externalContentValues = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
|
||||
readonly #externalContentVariants = new UmbArrayState(
|
||||
readonly #resolvedExternalContent = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
|
||||
readonly #resolvedExternalContentVariants = new UmbArrayState(
|
||||
<
|
||||
Array<{ key: string; variants: Array<{ culture: string | null; segment: string | null; state: string | null }> }>
|
||||
>[],
|
||||
@@ -201,14 +201,15 @@ export abstract class UmbBlockManagerContext<
|
||||
null,
|
||||
);
|
||||
|
||||
// Auto-resolve content for any layout marked as external, whenever layouts change.
|
||||
// Auto-resolve content for any layout marked as shared, whenever layouts change.
|
||||
this.observe(
|
||||
this._layouts.asObservable(),
|
||||
(layouts) => {
|
||||
const keys = layouts
|
||||
.filter((layout) => layout.isExternalContent && layout.contentKey)
|
||||
.map((layout) => layout.contentKey as string);
|
||||
if (keys.length) this.#fetchExternalContent(keys);
|
||||
layouts.forEach((layout) => {
|
||||
if (layout.isExternalContent && layout.contentKey) {
|
||||
this.#fetchExternalContent(layout.contentKey);
|
||||
}
|
||||
});
|
||||
},
|
||||
null,
|
||||
);
|
||||
@@ -327,12 +328,13 @@ export abstract class UmbBlockManagerContext<
|
||||
return mergeObservables(
|
||||
[
|
||||
this.#contents.asObservablePart((source) => source.find((x) => x.key === key)),
|
||||
this.#externalContentValues.asObservablePart((source) => source.find((x) => x.key === key)),
|
||||
this.#resolvedExternalContent.asObservablePart((source) => source.find((x) => x.key === key)),
|
||||
],
|
||||
([localContent, externalContent]) => localContent ?? externalContent ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: [LK] Review the naming of this property, align with with team.
|
||||
/**
|
||||
* Returns an observable that emits true when the layout for the given contentKey
|
||||
* has `isExternalContent` set (i.e., the block references external content).
|
||||
@@ -344,13 +346,14 @@ export abstract class UmbBlockManagerContext<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the external content variant state.
|
||||
* Returns an observable of the variant state for external content,
|
||||
* resolved against the manager's active variantId (culture/segment).
|
||||
* Emits the state string (e.g., 'Published', 'Draft') or null if not resolved yet.
|
||||
*/
|
||||
externalContentStateOf(key: string) {
|
||||
elementStateOf(key: string) {
|
||||
return mergeObservables(
|
||||
[
|
||||
this.#externalContentVariants.asObservablePart((source) => source.find((x) => x.key === key)),
|
||||
this.#resolvedExternalContentVariants.asObservablePart((source) => source.find((x) => x.key === key)),
|
||||
this.variantId,
|
||||
],
|
||||
([entry, variantId]) => {
|
||||
@@ -362,41 +365,41 @@ export abstract class UmbBlockManagerContext<
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: migrate to UmbRepositoryDetailsManager for true batched fetches [LK]
|
||||
async #fetchExternalContent(keys: Array<string>) {
|
||||
for (const key of keys) {
|
||||
if (this.#pendingElementFetches.has(key)) continue;
|
||||
if (this.#externalContentValues.getValue().some((x) => x.key === key)) continue;
|
||||
this.#pendingElementFetches.add(key);
|
||||
try {
|
||||
const { data } = await this.#elementRepository.requestByUnique(key);
|
||||
if (data) {
|
||||
const blockData: UmbBlockDataModel = {
|
||||
key: data.unique,
|
||||
contentTypeKey: data.documentType.unique,
|
||||
values: data.values.map(
|
||||
(v): UmbBlockDataValueModel => ({
|
||||
alias: v.alias,
|
||||
editorAlias: v.editorAlias,
|
||||
culture: v.culture,
|
||||
segment: v.segment,
|
||||
value: v.value,
|
||||
}),
|
||||
),
|
||||
};
|
||||
this.#externalContentValues.appendOne(blockData);
|
||||
this.#externalContentVariants.appendOne({
|
||||
key: data.unique,
|
||||
variants: data.variants.map((v) => ({
|
||||
culture: v.culture ?? null,
|
||||
segment: v.segment ?? null,
|
||||
state: v.state ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.#pendingElementFetches.delete(key);
|
||||
// TODO: [@madsrasmussen] Replace per-key fetches here with a batching manager that bundles multiple
|
||||
// element requests into a single round-trip. Today this issues one request per shared block, which
|
||||
// can become N+1 on pages with many references. The batching manager should also cache and dedupe.
|
||||
async #fetchExternalContent(key: string) {
|
||||
if (this.#pendingElementFetches.has(key)) return;
|
||||
if (this.#resolvedExternalContent.getValue().some((x) => x.key === key)) return;
|
||||
this.#pendingElementFetches.add(key);
|
||||
try {
|
||||
const { data } = await this.#elementRepository.requestByUnique(key);
|
||||
if (data) {
|
||||
const blockData: UmbBlockDataModel = {
|
||||
key: data.unique,
|
||||
contentTypeKey: data.documentType.unique,
|
||||
values: data.values.map(
|
||||
(v): UmbBlockDataValueModel => ({
|
||||
alias: v.alias,
|
||||
editorAlias: v.editorAlias,
|
||||
culture: v.culture,
|
||||
segment: v.segment,
|
||||
value: v.value,
|
||||
}),
|
||||
),
|
||||
};
|
||||
this.#resolvedExternalContent.appendOne(blockData);
|
||||
this.#resolvedExternalContentVariants.appendOne({
|
||||
key: data.unique,
|
||||
variants: data.variants.map((v) => ({
|
||||
culture: v.culture ?? null,
|
||||
segment: v.segment ?? null,
|
||||
state: v.state ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.#pendingElementFetches.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +465,7 @@ export abstract class UmbBlockManagerContext<
|
||||
getContentOf(contentKey: string) {
|
||||
return (
|
||||
this.#contents.value.find((x) => x.key === contentKey) ??
|
||||
this.#externalContentValues.value.find((x) => x.key === contentKey)
|
||||
this.#resolvedExternalContent.value.find((x) => x.key === contentKey)
|
||||
);
|
||||
}
|
||||
getSettingsOf(settingsKey: string) {
|
||||
@@ -518,10 +521,12 @@ export abstract class UmbBlockManagerContext<
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to transfer a local block's content to external content (Element Library).
|
||||
* Request to transfer a local block's content to the Element Library.
|
||||
* Opens the transfer modal for the user to name the new Element and pick a location,
|
||||
* then creates the Element and updates the block to reference it.
|
||||
* @param {string} key the block layout key.
|
||||
*/
|
||||
async requestTransferToExternalContent(key: string, name?: string) {
|
||||
async requestTransferToElementLibrary(key: string, name?: string) {
|
||||
const layout = this._layouts.getValue().find((x) => x.key === key);
|
||||
if (!layout) return;
|
||||
const contentKey = layout.contentKey;
|
||||
@@ -536,7 +541,8 @@ export abstract class UmbBlockManagerContext<
|
||||
.catch(() => undefined);
|
||||
if (!result) return;
|
||||
|
||||
const { data: scaffold } = await this.#elementRepository.createScaffold({
|
||||
const elementRepository = new UmbElementDetailRepository(this);
|
||||
const { data: scaffold } = await elementRepository.createScaffold({
|
||||
documentType: { unique: content.contentTypeKey, collection: null },
|
||||
values: content.values,
|
||||
variants: [
|
||||
@@ -553,7 +559,7 @@ export abstract class UmbBlockManagerContext<
|
||||
});
|
||||
if (!scaffold) return;
|
||||
|
||||
const { data: created } = await this.#elementRepository.create(scaffold, result.parentUnique);
|
||||
const { data: created } = await elementRepository.create(scaffold, result.parentUnique);
|
||||
if (!created) return;
|
||||
|
||||
this.#contents.removeOne(contentKey);
|
||||
@@ -565,10 +571,11 @@ export abstract class UmbBlockManagerContext<
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to disconnect a block from external content (Element Library).
|
||||
* Request to disconnect a block from the Element Library.
|
||||
* Asks for user confirmation, then copies the element content into local contentData.
|
||||
* @param {string} key the block layout key.
|
||||
*/
|
||||
async requestDisconnectFromExternalContent(key: string) {
|
||||
async requestDisconnectFromElementLibrary(key: string) {
|
||||
const layout = this._layouts.getValue().find((x) => x.key === key);
|
||||
if (!layout) return;
|
||||
const elementKey = layout.contentKey;
|
||||
@@ -584,7 +591,8 @@ export abstract class UmbBlockManagerContext<
|
||||
return; // user cancelled
|
||||
}
|
||||
|
||||
const { data: element } = await this.#elementRepository.requestByUnique(elementKey);
|
||||
const elementRepository = new UmbElementDetailRepository(this);
|
||||
const { data: element } = await elementRepository.requestByUnique(elementKey);
|
||||
if (!element) return;
|
||||
|
||||
const contentTypeKey = element.documentType.unique;
|
||||
@@ -606,8 +614,8 @@ export abstract class UmbBlockManagerContext<
|
||||
contentKey: newContent.key,
|
||||
isExternalContent: undefined,
|
||||
} as Partial<BlockLayoutType>);
|
||||
this.#externalContentValues.removeOne(elementKey);
|
||||
this.#externalContentVariants.removeOne(elementKey);
|
||||
this.#resolvedExternalContent.removeOne(elementKey);
|
||||
this.#resolvedExternalContentVariants.removeOne(elementKey);
|
||||
// Only set expose if the content type structure is loaded (it may not be for external content
|
||||
// whose type was not in the block type list)
|
||||
if (this.getStructure(contentTypeKey)) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface UmbBlockLayoutBaseModel {
|
||||
key: string;
|
||||
contentKey: string;
|
||||
settingsKey?: string | null;
|
||||
isExternalContent?: boolean;
|
||||
isExternalContent?: boolean; // TODO: [LK] Review the naming of this property, align with with team.
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
|
||||
+3
-1
@@ -247,7 +247,9 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
|
||||
manager.isExternalContentOf(contentKey),
|
||||
(isExternalContent) => {
|
||||
if (isExternalContent) {
|
||||
// External content does not keep an exposed state, so we default to true.
|
||||
// A library element references pre-existing external content. It is never exposed
|
||||
// per-variant the way local content is, so the expose lookup would always report
|
||||
// false. The block already exists, so it is established — submit reads as "Update".
|
||||
this.#exposed.setValue(true);
|
||||
return;
|
||||
}
|
||||
|
||||
+64
-12
@@ -17,6 +17,10 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
|
||||
=> builder.Services.Configure<ContentSettings>(config =>
|
||||
config.AllowEditInvariantFromNonDefault = true);
|
||||
|
||||
public static void ConfigureIndexExternalElementsTrue(IUmbracoBuilder builder)
|
||||
=> builder.Services.Configure<IndexingSettings>(config =>
|
||||
config.IndexExternalElements = true);
|
||||
|
||||
[Test]
|
||||
public async Task Can_Handle_Reusable_Element()
|
||||
{
|
||||
@@ -788,6 +792,7 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureIndexExternalElementsTrue))]
|
||||
public async Task Can_Include_Invariant_Reusable_Elements_In_Search_Indexing(bool published)
|
||||
{
|
||||
var elementType = await CreateElementType(ContentVariation.Nothing);
|
||||
@@ -832,18 +837,16 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
|
||||
contentTypeDictionary: new Dictionary<Guid, IContentType>
|
||||
{
|
||||
{ elementType.Key, elementType }, { contentType.Key, contentType },
|
||||
});
|
||||
}).ToList();
|
||||
|
||||
Assert.AreEqual(1, indexValues.Count());
|
||||
|
||||
var indexValue = indexValues.FirstOrDefault(v => v.Culture is null);
|
||||
var indexValue = indexValues.FirstOrDefault(v => v.Culture is null && v.FieldName == "blocks");
|
||||
Assert.IsNotNull(indexValue);
|
||||
Assert.AreEqual(1, indexValue.Values.Count());
|
||||
Assert.AreEqual(1, indexValue!.Values.Count());
|
||||
|
||||
var indexedValue = indexValue.Values.First() as string;
|
||||
Assert.IsNotNull(indexedValue);
|
||||
|
||||
var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
var values = indexedValue!.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.AreEqual(2, values.Length);
|
||||
Assert.Contains("The reusable invariant text", values);
|
||||
Assert.Contains("The reusable variant text", values);
|
||||
@@ -851,6 +854,7 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureIndexExternalElementsTrue))]
|
||||
public async Task Can_Include_Variant_Reusable_Elements_In_Search_Indexing(bool published)
|
||||
{
|
||||
var elementType = await CreateElementType(ContentVariation.Culture);
|
||||
@@ -895,27 +899,75 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
|
||||
contentTypeDictionary: new Dictionary<Guid, IContentType>
|
||||
{
|
||||
{ elementType.Key, elementType }, { contentType.Key, contentType },
|
||||
});
|
||||
|
||||
Assert.AreEqual(2, indexValues.Count());
|
||||
}).ToList();
|
||||
|
||||
AssertIndexValues("en-US", "The reusable English text");
|
||||
AssertIndexValues("da-DK", "The reusable Danish text");
|
||||
|
||||
void AssertIndexValues(string culture, string variantText)
|
||||
{
|
||||
var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture);
|
||||
var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture && v.FieldName == "blocks");
|
||||
Assert.IsNotNull(indexValue);
|
||||
Assert.AreEqual(1, indexValue.Values.Count());
|
||||
Assert.AreEqual(1, indexValue!.Values.Count());
|
||||
var indexedValue = indexValue.Values.First() as string;
|
||||
Assert.IsNotNull(indexedValue);
|
||||
var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
var values = indexedValue!.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.AreEqual(2, values.Length);
|
||||
Assert.Contains(variantText, values);
|
||||
Assert.Contains("The reusable invariant text", values);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Index_Shared_Element_Content_When_Opt_In_Disabled()
|
||||
{
|
||||
var elementType = await CreateElementType(ContentVariation.Nothing);
|
||||
var blockListDataType = await CreateBlockListDataType(elementType);
|
||||
var contentType = await CreateContentType(ContentVariation.Nothing, blockListDataType);
|
||||
|
||||
var reusableElementKey = await CreateAndPublishInvariantReusableElement(elementType.Key);
|
||||
|
||||
var blockListValue = new BlockListValue
|
||||
{
|
||||
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
|
||||
{
|
||||
{
|
||||
Constants.PropertyEditors.Aliases.BlockList,
|
||||
[
|
||||
new BlockListLayoutItem { ContentKey = reusableElementKey, IsExternalContent = true }
|
||||
]
|
||||
},
|
||||
},
|
||||
ContentData = [],
|
||||
SettingsData = [],
|
||||
Expose = [],
|
||||
};
|
||||
|
||||
var content = new ContentBuilder().WithContentType(contentType).WithName("Page").Build();
|
||||
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
|
||||
ContentService.Save(content);
|
||||
PublishContent(content, ["*"]);
|
||||
|
||||
var editor = blockListDataType.Editor!;
|
||||
var indexValues = editor.PropertyIndexValueFactory.GetIndexValues(
|
||||
content.Properties["blocks"]!,
|
||||
culture: null,
|
||||
segment: null,
|
||||
published: true,
|
||||
availableCultures: ["en-US"],
|
||||
contentTypeDictionary: new Dictionary<Guid, IContentType>
|
||||
{
|
||||
{ elementType.Key, elementType }, { contentType.Key, contentType },
|
||||
}).ToList();
|
||||
|
||||
// Element content must NOT be indexed when opt-in is disabled.
|
||||
var allText = string.Join(
|
||||
Environment.NewLine,
|
||||
indexValues.SelectMany(v => v.Values).OfType<string>());
|
||||
Assert.IsFalse(allText.Contains("The reusable invariant text"), "Shared-element content must not be indexed when the opt-in is disabled.");
|
||||
Assert.IsFalse(allText.Contains("The reusable variant text"), "Shared-element content must not be indexed when the opt-in is disabled.");
|
||||
}
|
||||
|
||||
private async Task<IDataType> CreateBlockListDataType(IContentType elementType)
|
||||
=> await CreateBlockEditorDataType(
|
||||
Constants.PropertyEditors.Aliases.BlockList,
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Relations;
|
||||
using Umbraco.Cms.Infrastructure.Search;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Builders.Extensions;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
|
||||
using IUmbracoBuilder = Umbraco.Cms.Core.DependencyInjection.IUmbracoBuilder;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Search;
|
||||
|
||||
internal sealed class DeferredSearchReindexServiceElementTests : BlockEditorWithReusableContentTestBase
|
||||
{
|
||||
private DeferredSearchReindexService Service
|
||||
=> (DeferredSearchReindexService)GetRequiredService<IDeferredSearchReindexService>();
|
||||
|
||||
private int ElementId(Guid key) => IdKeyMap.GetIdForKey(key, UmbracoObjectTypes.Element).Result;
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
base.CustomTestSetup(builder);
|
||||
builder
|
||||
.AddNotificationHandler<ContentSavedNotification, ContentRelationsUpdate>()
|
||||
.AddNotificationHandler<ContentPublishedNotification, ContentRelationsUpdate>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finds_Document_Directly_Referencing_Element()
|
||||
{
|
||||
var elementType = await CreateElementType(ContentVariation.Nothing);
|
||||
var blockListDataType = await CreateBlockListDataType(elementType);
|
||||
var contentType = await CreateContentType(ContentVariation.Nothing, blockListDataType);
|
||||
|
||||
var elementKey = await CreateAndPublishInvariantReusableElement(elementType.Key);
|
||||
var content = CreateDocumentEmbeddingElement(contentType, elementKey);
|
||||
|
||||
var elementId = ElementId(elementKey);
|
||||
var documentIds = Service.FindDocumentIdsReferencingElements([elementId]);
|
||||
|
||||
Assert.Contains(content.Id, documentIds.ToArray());
|
||||
}
|
||||
|
||||
private IContent CreateDocumentEmbeddingElement(IContentType contentType, Guid sharedElementKey)
|
||||
{
|
||||
var blockListValue = new BlockListValue
|
||||
{
|
||||
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
|
||||
{
|
||||
{
|
||||
Constants.PropertyEditors.Aliases.BlockList,
|
||||
[new BlockListLayoutItem { ContentKey = sharedElementKey, IsExternalContent = true }]
|
||||
},
|
||||
},
|
||||
ContentData = [],
|
||||
SettingsData = [],
|
||||
Expose = [],
|
||||
};
|
||||
|
||||
var content = new ContentBuilder().WithContentType(contentType).WithName("Page").Build();
|
||||
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
|
||||
ContentService.Save(content);
|
||||
PublishContent(content, ["*"]);
|
||||
return content;
|
||||
}
|
||||
|
||||
private async Task<IDataType> CreateBlockListDataType(IContentType elementType)
|
||||
=> await CreateBlockEditorDataType(
|
||||
Constants.PropertyEditors.Aliases.BlockList,
|
||||
new BlockListConfiguration.BlockConfiguration[]
|
||||
{
|
||||
new() { ContentElementTypeKey = elementType.Key, SettingsElementTypeKey = elementType.Key }
|
||||
});
|
||||
}
|
||||
+7
-7
@@ -45,7 +45,7 @@ public class BlockEditorComponentTests
|
||||
|
||||
Assert.AreEqual(5, guidMap.Count); // 5 keys from Block List (with no sub features)
|
||||
var expected = ReplaceGuids(json, guidMap);
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -77,7 +77,7 @@ public class BlockEditorComponentTests
|
||||
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
|
||||
var expected = ReplaceGuids(GetBlockListJson(innerJsonEscaped), guidMap);
|
||||
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -104,7 +104,7 @@ public class BlockEditorComponentTests
|
||||
|
||||
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
|
||||
var expected = ReplaceGuids(GetBlockListJson(innerJson), guidMap);
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -138,7 +138,7 @@ public class BlockEditorComponentTests
|
||||
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
|
||||
var expected = ReplaceGuids(GetBlockListJson(GetGridJson(innerJsonEscaped)), guidMap);
|
||||
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -169,7 +169,7 @@ public class BlockEditorComponentTests
|
||||
Assert.AreEqual(21, guidMap.Count); // 16 keys from Block Grid + 5 keys from Block List
|
||||
var expected = ReplaceGuids(GetBlockGridJson(innerJsonEscaped), guidMap);
|
||||
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -196,7 +196,7 @@ public class BlockEditorComponentTests
|
||||
Assert.AreEqual(21, guidMap.Count); // 16 keys from Block Grid + 5 keys from Block List
|
||||
var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap);
|
||||
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
@@ -229,7 +229,7 @@ public class BlockEditorComponentTests
|
||||
Assert.AreEqual(16, guidMap.Count); // 16 keys from Block Grid (with no sub features applicable for replacement)
|
||||
var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap);
|
||||
|
||||
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
|
||||
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
|
||||
Assert.IsNotEmpty(resultJson);
|
||||
Assert.AreEqual(expectedJson, resultJson);
|
||||
|
||||
+67
-1
@@ -10,6 +10,7 @@ using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
@@ -30,6 +31,7 @@ public class DeferredSearchReindexServiceTests
|
||||
private Mock<IUmbracoIndexingHandler> _umbracoIndexingHandler = null!;
|
||||
private Mock<IPublishStatusQueryService> _publishStatusQueryService = null!;
|
||||
private Mock<ICoreScopeProvider> _scopeProvider = null!;
|
||||
private Mock<IRelationService> _relationService = null!;
|
||||
private DeferredSearchReindexService _service = null!;
|
||||
|
||||
[SetUp]
|
||||
@@ -41,6 +43,7 @@ public class DeferredSearchReindexServiceTests
|
||||
_umbracoIndexingHandler = new Mock<IUmbracoIndexingHandler>();
|
||||
_publishStatusQueryService = new Mock<IPublishStatusQueryService>();
|
||||
_scopeProvider = new Mock<ICoreScopeProvider>();
|
||||
_relationService = new Mock<IRelationService>();
|
||||
_scopeProvider
|
||||
.Setup(x => x.CreateCoreScope(
|
||||
It.IsAny<System.Data.IsolationLevel>(),
|
||||
@@ -83,7 +86,8 @@ public class DeferredSearchReindexServiceTests
|
||||
indexingSettings.Object,
|
||||
_scopeProvider.Object,
|
||||
Mock.Of<ILogger<DeferredSearchReindexService>>(),
|
||||
lifetime.Object);
|
||||
lifetime.Object,
|
||||
_relationService.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -434,6 +438,68 @@ public class DeferredSearchReindexServiceTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a document transitively embedding an element (via an intermediate element) is included in the
|
||||
/// reindex set.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Finds_Document_Transitively_Referencing_Element()
|
||||
{
|
||||
// Document 100 embeds element 1; element 1 embeds element 2 (the one that changes).
|
||||
SetupRelationGraph(new Dictionary<(int childId, UmbracoObjectTypes type), int[]>
|
||||
{
|
||||
{ (2, UmbracoObjectTypes.Document), [] },
|
||||
{ (2, UmbracoObjectTypes.Element), [1] },
|
||||
{ (1, UmbracoObjectTypes.Document), [100] },
|
||||
{ (1, UmbracoObjectTypes.Element), [] },
|
||||
});
|
||||
|
||||
var documentIds = _service.FindDocumentIdsReferencingElements([2]);
|
||||
|
||||
CollectionAssert.AreEquivalent(new[] { 100 }, documentIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the BFS terminates and does not loop when element references are cyclic.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Terminates_On_Cyclic_Element_References()
|
||||
{
|
||||
// Element 1 <-> element 2 (cycle); document 100 embeds element 1. Element 2 changes.
|
||||
SetupRelationGraph(new Dictionary<(int childId, UmbracoObjectTypes type), int[]>
|
||||
{
|
||||
{ (2, UmbracoObjectTypes.Document), [] },
|
||||
{ (2, UmbracoObjectTypes.Element), [1] },
|
||||
{ (1, UmbracoObjectTypes.Document), [100] },
|
||||
{ (1, UmbracoObjectTypes.Element), [2] },
|
||||
});
|
||||
|
||||
var documentIds = _service.FindDocumentIdsReferencingElements([2]);
|
||||
|
||||
CollectionAssert.AreEquivalent(new[] { 100 }, documentIds);
|
||||
}
|
||||
|
||||
private void SetupRelationGraph(Dictionary<(int childId, UmbracoObjectTypes type), int[]> graph)
|
||||
{
|
||||
_relationService
|
||||
.Setup(r => r.GetPagedParentEntitiesByChildId(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<int>(),
|
||||
out It.Ref<long>.IsAny,
|
||||
It.IsAny<UmbracoObjectTypes[]>()))
|
||||
.Returns((int id, long pageIndex, int pageSize, out long total, UmbracoObjectTypes[] types) =>
|
||||
{
|
||||
UmbracoObjectTypes type = types.Length > 0 ? types[0] : UmbracoObjectTypes.Unknown;
|
||||
int[] parents = graph.TryGetValue((id, type), out int[]? ids) ? ids : [];
|
||||
total = parents.Length;
|
||||
return pageIndex == 0 ? parents.Select(CreateEntity).ToArray() : [];
|
||||
});
|
||||
}
|
||||
|
||||
private static IUmbracoEntity CreateEntity(int id)
|
||||
=> Mock.Of<IUmbracoEntity>(e => e.Id == id);
|
||||
|
||||
private static IContent CreateContent(int id, bool published)
|
||||
{
|
||||
var content = new Mock<IContent>();
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Search;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Search;
|
||||
|
||||
[TestFixture]
|
||||
public class ElementIndexingNotificationHandlerTests
|
||||
{
|
||||
private Mock<IDeferredSearchReindexService> _mockReindexService = null!;
|
||||
private ElementIndexingNotificationHandler _sut = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mockReindexService = new Mock<IDeferredSearchReindexService>();
|
||||
_sut = new ElementIndexingNotificationHandler(_mockReindexService.Object);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_ElementSavedNotification_QueuesElementIds()
|
||||
{
|
||||
var element1 = ElementWithId(10);
|
||||
var element2 = ElementWithId(20);
|
||||
var notification = new ElementSavedNotification([element1, element2], new EventMessages());
|
||||
|
||||
_sut.Handle(notification);
|
||||
|
||||
_mockReindexService.Verify(
|
||||
s => s.QueueElementReindex(It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 10, 20 }))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_ElementPublishedNotification_QueuesElementIds()
|
||||
{
|
||||
var element1 = ElementWithId(30);
|
||||
var element2 = ElementWithId(40);
|
||||
var notification = new ElementPublishedNotification([element1, element2], new EventMessages());
|
||||
|
||||
_sut.Handle(notification);
|
||||
|
||||
_mockReindexService.Verify(
|
||||
s => s.QueueElementReindex(It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 30, 40 }))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_ElementSavedNotification_WhenEmpty_DoesNotQueue()
|
||||
{
|
||||
var notification = new ElementSavedNotification([], new EventMessages());
|
||||
|
||||
_sut.Handle(notification);
|
||||
|
||||
_mockReindexService.Verify(s => s.QueueElementReindex(It.IsAny<IReadOnlyCollection<int>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_ElementPublishedNotification_WhenEmpty_DoesNotQueue()
|
||||
{
|
||||
var notification = new ElementPublishedNotification([], new EventMessages());
|
||||
|
||||
_sut.Handle(notification);
|
||||
|
||||
_mockReindexService.Verify(s => s.QueueElementReindex(It.IsAny<IReadOnlyCollection<int>>()), Times.Never);
|
||||
}
|
||||
|
||||
private static IElement ElementWithId(int id)
|
||||
{
|
||||
var mock = new Mock<IElement>();
|
||||
mock.Setup(e => e.Id).Returns(id);
|
||||
return mock.Object;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user