Merge branch 'v14/dev' into contrib

This commit is contained in:
Jason Elkin
2024-07-04 12:56:41 +01:00
34 changed files with 2596 additions and 843 deletions

View File

@@ -63,15 +63,14 @@ variables:
DOTNET_GENERATE_ASPNET_CERTIFICATE: false
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
npm_config_cache: $(Pipeline.Workspace)/.npm_client
NODE_OPTIONS: --max_old_space_size=16384
stages:
###############################################
## Build
###############################################
- stage: Build
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_client
NODE_OPTIONS: --max_old_space_size=16384
jobs:
- job: A
displayName: Build Umbraco CMS
@@ -80,18 +79,11 @@ stages:
steps:
- checkout: self
submodules: true
- task: NodeTool@0
displayName: Use Node.js $(nodeVersion)
retryCountOnTaskFailure: 3
- task: UseDotNet@2
displayName: Use .NET SDK from global.json
inputs:
versionSpec: $(nodeVersion)
- script: npm ci --no-fund --no-audit --prefer-offline
displayName: Run npm ci (Bellissima)
workingDirectory: src/Umbraco.Web.UI.Client
- script: npm run generate:api-local
displayName: Generate API models (Bellissima)
workingDirectory: src/Umbraco.Web.UI.Client
enabled: false
useGlobalJson: true
- template: templates/backoffice-install.yml
- script: npm run build:for:cms
displayName: Run build (Bellissima)
workingDirectory: src/Umbraco.Web.UI.Client
@@ -101,10 +93,6 @@ stages:
- script: npm run build
displayName: Run npm build (Login)
workingDirectory: src/Umbraco.Web.UI.Login
- task: UseDotNet@2
displayName: Use .NET SDK from global.json
inputs:
useGlobalJson: true
- task: DotNetCoreCLI@2
displayName: Run dotnet restore
inputs:
@@ -127,18 +115,25 @@ stages:
inputs:
targetPath: $(Build.SourcesDirectory)
artifactName: build_output
- job: B
displayName: Build Bellissima Package
pool:
vmImage: 'ubuntu-latest'
steps:
- checkout: self
submodules: true
- template: templates/backoffice-install.yml
- script: npm run build:for:npm
displayName: Run build:for:npm
workingDirectory: src/Umbraco.Web.UI.Client
- bash: |
echo "##[command]Running npm version"
echo "##[debug]Version: $PACKAGE_VERSION"
echo "##[command]Running npm pack"
echo "##[debug]Output directory: $(Build.ArtifactStagingDirectory)"
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
mkdir $(Build.ArtifactStagingDirectory)/npm
npm pack --pack-destination $(Build.ArtifactStagingDirectory)/npm
mv .npmrc $(Build.ArtifactStagingDirectory)/npm/
displayName: Prepare Bellissima npm package
env:
PACKAGE_VERSION: $(build.NBGV_NpmPackageVersion)
displayName: Run npm pack
workingDirectory: src/Umbraco.Web.UI.Client
- task: PublishPipelineArtifact@1
displayName: Publish Bellissima npm artifact
@@ -208,28 +203,11 @@ stages:
pool:
vmImage: 'ubuntu-latest'
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_client
NODE_OPTIONS: --max_old_space_size=16384
BASE_PATH: /v$(umbracoMajorVersion)/ui
steps:
- checkout: self
submodules: true
- task: NodeTool@0
displayName: Use Node.js $(nodeVersion)
retryCountOnTaskFailure: 3
inputs:
versionSpec: $(nodeVersion)
- task: Cache@2
displayName: Cache node_modules
inputs:
key: '"npm_client" | "$(Agent.OS)"| $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client/package-lock.json'
restoreKeys: |
"npm_client" | "$(Agent.OS)"
"npm_client"
path: $(npm_config_cache)
- script: npm ci --no-fund --no-audit --prefer-offline
workingDirectory: src/Umbraco.Web.UI.Client
displayName: Run npm ci
- template: templates/backoffice-install.yml
- script: npm run storybook:build
displayName: Build Storybook
env:
@@ -239,16 +217,30 @@ stages:
displayName: Replace BASE_PATH on assets
workingDirectory: $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client/storybook-static
- task: ArchiveFiles@2
displayName: Archive js Docs
displayName: Archive Storybook
inputs:
rootFolderOrFile: $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client/storybook-static
includeRootFolder: false
archiveFile: $(Build.ArtifactStagingDirectory)/ui-docs.zip
- task: PublishPipelineArtifact@1
displayName: Publish js Docs
displayName: Publish Storybook
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/ui-docs.zip
artifact: ui-docs
- script: npm run generate:ui-api-docs
displayName: Generate API Docs
workingDirectory: $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client
- task: ArchiveFiles@2
displayName: Archive UI API Docs
inputs:
rootFolderOrFile: $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client/ui-api
includeRootFolder: false
archiveFile: $(Build.ArtifactStagingDirectory)/ui-api-docs.zip
- task: PublishPipelineArtifact@1
displayName: Publish UI API Docs
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/ui-api-docs.zip
artifact: ui-api-docs
###############################################
## Test
@@ -528,7 +520,7 @@ stages:
- ${{ if eq(parameters.isNightly, true) }}:
pwsh: npm run test --ignore-certificate-errors
${{ else }}:
pwsh: npm run smokeTest --ignore-certificate-errors
pwsh: npm run smokeTest --ignore-certificate-errors
displayName: Run Playwright tests
continueOnError: true
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
@@ -868,7 +860,7 @@ stages:
BlobPrefix: v$(umbracoMajorVersion)/csharp
CleanTargetBeforeCopy: true
- job:
displayName: Upload js Docs
displayName: Upload Storybook
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
@@ -881,7 +873,7 @@ stages:
archiveFilePatterns: $(Build.SourcesDirectory)/ui-docs.zip
destinationFolder: $(Build.ArtifactStagingDirectory)/ui-docs
- task: AzureFileCopy@4
displayName: 'Copy UI Docs to blob storage'
displayName: 'Copy Storybook to blob storage'
inputs:
SourcePath: '$(Build.ArtifactStagingDirectory)/ui-docs/*'
azureSubscription: umbraco-storage
@@ -890,3 +882,26 @@ stages:
ContainerName: '$web'
BlobPrefix: v$(umbracoMajorVersion)/ui
CleanTargetBeforeCopy: true
- job:
displayName: Upload UI API Docs
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
displayName: Download artifact
inputs:
artifact: ui-api-docs
path: $(Build.SourcesDirectory)
- task: ExtractFiles@1
inputs:
archiveFilePatterns: $(Build.SourcesDirectory)/ui-api-docs.zip
destinationFolder: $(Build.ArtifactStagingDirectory)/ui-api-docs
- task: AzureFileCopy@4
displayName: 'Copy UI API Docs to blob storage'
inputs:
SourcePath: '$(Build.ArtifactStagingDirectory)/ui-api-docs/*'
azureSubscription: umbraco-storage
Destination: AzureBlob
storage: umbracoapidocs
ContainerName: '$web'
BlobPrefix: v$(umbracoMajorVersion)/ui-api
CleanTargetBeforeCopy: true

View File

@@ -0,0 +1,31 @@
steps:
- task: NodeTool@0
displayName: Use Node.js
retryCountOnTaskFailure: 3
inputs:
versionSource: 'fromFile'
versionFilePath: src/Umbraco.Web.UI.Client/.nvmrc
- bash: |
echo "##[command]Install nbgv"
dotnet tool install --tool-path . nbgv
echo "##[command]Running nbgv get-version"
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
echo "##[command]Running npm version"
echo "##[debug]Version: $PACKAGE_VERSION"
cd src/Umbraco.Web.UI.Client
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
displayName: Set NPM Version
- task: Cache@2
displayName: Cache node_modules
inputs:
key: '"npm_client" | "$(Agent.OS)"| $(Build.SourcesDirectory)/src/Umbraco.Web.UI.Client/package-lock.json'
restoreKeys: |
"npm_client" | "$(Agent.OS)"
"npm_client"
path: $(npm_config_cache)
- script: npm ci --no-fund --no-audit --prefer-offline
displayName: Run npm ci (Bellissima)
workingDirectory: src/Umbraco.Web.UI.Client

View File

@@ -86,6 +86,18 @@ public abstract class DocumentTypeControllerBase : ManagementApiControllerBase
.WithTitle("Operation not permitted")
.WithDetail("The attempted operation was not permitted, likely due to a permission/configuration mismatch with the operation.")
.Build()),
ContentTypeOperationStatus.CancelledByNotification => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Cancelled by notification")
.WithDetail("The attempted operation was cancelled by a notification.")
.Build()),
ContentTypeOperationStatus.NameCannotBeEmpty => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Name cannot be empty")
.WithDetail("The name of the content type cannot be empty")
.Build()),
ContentTypeOperationStatus.NameTooLong => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Name was too long")
.WithDetail("Name cannot be more than 255 characters in length.")
.Build()),
_ => new ObjectResult("Unknown content type operation status") { StatusCode = StatusCodes.Status500InternalServerError },
});

View File

@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
@@ -29,7 +29,7 @@ public class ByRelationTypeKeyRelationController : RelationControllerBase
/// <remarks>
/// Use case: On a relation type page you can see all created relations of this type.
/// </remarks>
[HttpGet("type/{id:guid}")]
[HttpGet("type/{id:guid}", Name = "GetRelationByRelationTypeId")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<RelationResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(PagedViewModel<ProblemDetails>), StatusCodes.Status404NotFound)]

View File

@@ -34,7 +34,7 @@ internal static class BackOfficeCorsPolicyBuilderExtensions
{
policy
.WithOrigins(customOrigin)
.WithExposedHeaders(Constants.Headers.Location, Constants.Headers.GeneratedResource)
.WithExposedHeaders(Constants.Headers.Location, Constants.Headers.GeneratedResource, Constants.Headers.Notifications)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();

View File

@@ -1,10 +1,15 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Middleware;
@@ -16,15 +21,40 @@ public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private readonly IServiceProvider _serviceProvider;
private readonly IRuntimeState _runtimeState;
private readonly IOptions<GlobalSettings> _globalSettings;
private readonly IOptions<WebRoutingSettings> _webRoutingSettings;
private readonly IHostingEnvironment _hostingEnvironment;
[Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 16.")]
public BackOfficeAuthorizationInitializationMiddleware(
UmbracoRequestPaths umbracoRequestPaths,
IServiceProvider serviceProvider,
IRuntimeState runtimeState)
: this(
umbracoRequestPaths,
serviceProvider,
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<GlobalSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<IOptions<WebRoutingSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<IHostingEnvironment>()
)
{
}
public BackOfficeAuthorizationInitializationMiddleware(
UmbracoRequestPaths umbracoRequestPaths,
IServiceProvider serviceProvider,
IRuntimeState runtimeState,
IOptions<GlobalSettings> globalSettings,
IOptions<WebRoutingSettings> webRoutingSettings,
IHostingEnvironment hostingEnvironment)
{
_umbracoRequestPaths = umbracoRequestPaths;
_serviceProvider = serviceProvider;
_runtimeState = runtimeState;
_globalSettings = globalSettings;
_webRoutingSettings = webRoutingSettings;
_hostingEnvironment = hostingEnvironment;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@@ -47,6 +77,7 @@ public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware
return;
}
if (_umbracoRequestPaths.IsBackOfficeRequest(context.Request.Path) == false)
{
return;
@@ -55,9 +86,13 @@ public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware
await _firstBackOfficeRequestLocker.WaitAsync();
if (_firstBackOfficeRequest == false)
{
Uri? backOfficeUrl = string.IsNullOrWhiteSpace(_webRoutingSettings.Value.UmbracoApplicationUrl) is false
? new Uri($"{_webRoutingSettings.Value.UmbracoApplicationUrl.TrimEnd('/')}{_globalSettings.Value.GetBackOfficePath(_hostingEnvironment).EnsureStartsWith('/')}")
: null;
using IServiceScope scope = _serviceProvider.CreateScope();
IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>();
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(new Uri(context.Request.GetDisplayUrl()));
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(backOfficeUrl ?? new Uri(context.Request.GetDisplayUrl()));
_firstBackOfficeRequest = true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,6 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web.Mvc;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Extensions;
@@ -54,8 +52,8 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes
case RuntimeLevel.Install:
case RuntimeLevel.Upgrade:
case RuntimeLevel.Run:
MapMinimalBackOffice(endpoints);
endpoints.MapHub<BackofficeHub>(_umbracoPathSegment + Constants.Web.BackofficeSignalRHub);
break;
case RuntimeLevel.BootFailed:
case RuntimeLevel.Unknown:

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.SignalR;
namespace Umbraco.Cms.Api.Management.Routing;
public class BackofficeHub : Hub
{
public async Task SendPayload(object payload) => await Clients.All.SendAsync("payloadReceived", payload);
}

View File

@@ -56,6 +56,7 @@ public static partial class Constants
/// The "base" path to the Management API
/// </summary>
public const string ManagementApiPath = "/management/api/";
public const string BackofficeSignalRHub = "/backofficeHub";
public static class Routing
{

View File

@@ -40,7 +40,12 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
UpdateTemplates(contentType, model);
// save content type
await SaveAsync(contentType, userKey);
Attempt<ContentTypeOperationStatus> creationAttempt = await _contentTypeService.CreateAsync(contentType, userKey);
if(creationAttempt.Success is false)
{
return Attempt.FailWithStatus<IContentType?, ContentTypeOperationStatus>(creationAttempt.Result, contentType);
}
return Attempt.SucceedWithStatus<IContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
}
@@ -58,9 +63,11 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
UpdateHistoryCleanup(contentType, model);
UpdateTemplates(contentType, model);
await SaveAsync(contentType, userKey);
Attempt<ContentTypeOperationStatus> attempt = await _contentTypeService.UpdateAsync(contentType, userKey);
return Attempt.SucceedWithStatus<IContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
return attempt.Success
? Attempt.SucceedWithStatus<IContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType)
: Attempt.FailWithStatus<IContentType?, ContentTypeOperationStatus>(attempt.Result, null);
}
public async Task<IEnumerable<ContentTypeAvailableCompositionsResult>> GetAvailableCompositionsAsync(
@@ -93,9 +100,6 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
contentType.SetDefaultTemplate(allowedTemplates.FirstOrDefault(t => t.Key == model.DefaultTemplateKey));
}
private async Task SaveAsync(IContentType contentType, Guid userKey)
=> await _contentTypeService.SaveAsync(contentType, userKey);
protected override IContentType CreateContentType(IShortStringHelper shortStringHelper, int parentId)
=> new ContentType(shortStringHelper, parentId);

View File

@@ -603,6 +603,75 @@ public abstract class ContentTypeServiceBase<TRepository, TItem> : ContentTypeSe
}
}
public async Task<Attempt<ContentTypeOperationStatus>> CreateAsync(TItem item, Guid performingUserKey) => await InternalSaveAsync(item, performingUserKey);
public async Task<Attempt<ContentTypeOperationStatus>> UpdateAsync(TItem item, Guid performingUserKey) => await InternalSaveAsync(item, performingUserKey);
private async Task<Attempt<ContentTypeOperationStatus>> InternalSaveAsync(TItem item, Guid performingUserKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
EventMessages eventMessages = EventMessagesFactory.Get();
Attempt<ContentTypeOperationStatus> validationAttempt = ValidateCommon(item);
if (validationAttempt.Success is false)
{
return Attempt.Fail(validationAttempt.Result);
}
SavingNotification<TItem> savingNotification = GetSavingNotification(item, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
{
scope.Complete();
return Attempt.Fail(ContentTypeOperationStatus.CancelledByNotification);
}
scope.WriteLock(WriteLockIds);
// validate the DAG transform, within the lock
ValidateLocked(item); // throws if invalid
int userId = await _userIdKeyResolver.GetAsync(performingUserKey);
item.CreatorId = userId;
if (item.Description == string.Empty)
{
item.Description = null;
}
Repository.Save(item); // also updates content/media/member items
// figure out impacted content types
ContentTypeChange<TItem>[] changes = ComposeContentTypeChanges(item).ToArray();
// Publish this in scope, see comment at GetContentTypeRefreshedNotification for more info.
await _eventAggregator.PublishAsync(GetContentTypeRefreshedNotification(changes, eventMessages));
scope.Notifications.Publish(GetContentTypeChangedNotification(changes, eventMessages));
SavedNotification<TItem> savedNotification = GetSavedNotification(item, eventMessages);
savedNotification.WithStateFrom(savingNotification);
scope.Notifications.Publish(savedNotification);
Audit(AuditType.Save, userId, item.Id);
scope.Complete();
return Attempt.Succeed(ContentTypeOperationStatus.Success);
}
private Attempt<ContentTypeOperationStatus> ValidateCommon(TItem item)
{
if (string.IsNullOrWhiteSpace(item.Name))
{
return Attempt.Fail(ContentTypeOperationStatus.NameCannotBeEmpty);
}
if (item.Name.Length > 255)
{
return Attempt.Fail(ContentTypeOperationStatus.NameTooLong);
}
return Attempt.Succeed(ContentTypeOperationStatus.Success);
}
#endregion
#region Delete

View File

@@ -67,16 +67,31 @@ public interface IContentTypeBaseService<TItem> : IContentTypeBaseService, IServ
bool HasChildren(Guid id);
[Obsolete("Please use the respective Create or Update instead")]
void Save(TItem? item, int userId = Constants.Security.SuperUserId);
[Obsolete("Please use the respective Create or Update instead")]
Task SaveAsync(TItem item, Guid performingUserKey)
{
Save(item);
return Task.CompletedTask;
}
[Obsolete("Please use the respective Create or Update instead")]
void Save(IEnumerable<TItem> items, int userId = Constants.Security.SuperUserId);
Task<Attempt<ContentTypeOperationStatus>> CreateAsync(TItem item, Guid performingUserKey)
{
Save(item);
return Task.FromResult(Attempt.Succeed(ContentTypeOperationStatus.Success));
}
Task<Attempt<ContentTypeOperationStatus>> UpdateAsync(TItem item, Guid performingUserKey)
{
Save(item);
return Task.FromResult(Attempt.Succeed(ContentTypeOperationStatus.Success));
}
void Delete(TItem item, int userId = Constants.Security.SuperUserId);
/// <summary>

View File

@@ -5,6 +5,8 @@ public enum ContentTypeOperationStatus
Success,
DuplicateAlias,
InvalidAlias,
NameCannotBeEmpty,
NameTooLong,
InvalidPropertyTypeAlias,
PropertyTypeAliasCannotEqualContentTypeAlias,
DuplicatePropertyTypeAlias,
@@ -17,5 +19,6 @@ public enum ContentTypeOperationStatus
MissingContainer,
DuplicateContainer,
NotFound,
NotAllowed
NotAllowed,
CancelledByNotification,
}

View File

@@ -10,6 +10,13 @@ namespace Umbraco.Cms.Core.Templates;
/// </summary>
public sealed class HtmlLocalLinkParser
{
// needs to support media and document links, order of attributes should not matter nor should other attributes mess with things
// <a type="media" href="/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}" title="media">media</a>
// <a type="document" href="/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}" title="other page">other page</a>
internal static readonly Regex LocalLinkTagPattern = new(
@"<a\s+(?:(?:(?:type=['""](?<type>document|media)['""].*?(?<locallink>href=[""']/{localLink:(?<guid>[a-fA-F0-9-]+)})[""'])|((?<locallink>href=[""']/{localLink:(?<guid>[a-fA-F0-9-]+)})[""'].*?type=(['""])(?<type>document|media)(?:['""])))|(?:(?:type=['""](?<type>document|media)['""])|(?:(?<locallink>href=[""']/{localLink:[a-fA-F0-9-]+})[""'])))[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
internal static readonly Regex LocalLinkPattern = new(
@"href=""[/]?(?:\{|\%7B)localLink:([a-zA-Z0-9-://]+)(?:\}|\%7D)",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
@@ -105,6 +112,32 @@ public sealed class HtmlLocalLinkParser
}
private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLocalLinkIds(string text)
{
MatchCollection localLinkTagMatches = LocalLinkTagPattern.Matches(text);
foreach (Match linkTag in localLinkTagMatches)
{
if (linkTag.Groups.Count < 1)
{
continue;
}
if (Guid.TryParse(linkTag.Groups["guid"].Value, out Guid guid) is false)
{
continue;
}
yield return (null, new GuidUdi(linkTag.Groups["type"].Value, guid), linkTag.Groups["locallink"].Value);
}
// also return legacy results for values that have not been migrated
foreach ((int? intId, GuidUdi? udi, string tagValue) legacyResult in FindLegacyLocalLinkIds(text))
{
yield return legacyResult;
}
}
// todo remove at some point?
private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLegacyLocalLinkIds(string text)
{
// Parse internal links
MatchCollection tags = LocalLinkPattern.Matches(text);

View File

@@ -132,9 +132,15 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
return;
}
if (attributes.ContainsKey("type") is false || attributes["type"] is not string type)
{
type = "unknown";
}
ReplaceLocalLinks(
publishedSnapshot,
href,
type,
route =>
{
attributes["route"] = route;

View File

@@ -52,8 +52,9 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
foreach (HtmlNode link in links)
{
ReplaceLocalLinks(
publishedSnapshot,
publishedSnapshot,
link.GetAttributeValue("href", string.Empty),
link.GetAttributeValue("type", "unknown"),
route =>
{
link.SetAttributeValue("href", route.Path);

View File

@@ -4,6 +4,7 @@ using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Templates;
namespace Umbraco.Cms.Infrastructure.DeliveryApi;
@@ -18,20 +19,35 @@ internal abstract partial class ApiRichTextParserBase
_apiMediaUrlProvider = apiMediaUrlProvider;
}
protected void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, string href, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl, Action handleInvalidLink)
protected void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, string href, string type, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl, Action handleInvalidLink)
{
ReplaceStatus replaceAttempt = ReplaceLocalLink(publishedSnapshot, href, type, handleContentRoute, handleMediaUrl);
if (replaceAttempt == ReplaceStatus.Success)
{
return;
}
if (replaceAttempt == ReplaceStatus.InvalidEntityType || ReplaceLegacyLocalLink(publishedSnapshot, href, handleContentRoute, handleMediaUrl) == ReplaceStatus.InvalidEntityType)
{
handleInvalidLink();
}
}
private ReplaceStatus ReplaceLocalLink(IPublishedSnapshot publishedSnapshot, string href, string type, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl)
{
Match match = LocalLinkRegex().Match(href);
if (match.Success is false)
{
return;
return ReplaceStatus.NoMatch;
}
if (UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) is false)
if (Guid.TryParse(match.Groups["guid"].Value, out Guid guid) is false)
{
return;
return ReplaceStatus.NoMatch;
}
bool handled = false;
var udi = new GuidUdi(type, guid);
switch (udi.EntityType)
{
case Constants.UdiEntityType.Document:
@@ -41,8 +57,8 @@ internal abstract partial class ApiRichTextParserBase
: null;
if (route != null)
{
handled = true;
handleContentRoute(route);
return ReplaceStatus.Success;
}
break;
@@ -50,17 +66,56 @@ internal abstract partial class ApiRichTextParserBase
IPublishedContent? media = publishedSnapshot.Media?.GetById(udi);
if (media != null)
{
handled = true;
handleMediaUrl(_apiMediaUrlProvider.GetUrl(media));
return ReplaceStatus.Success;
}
break;
}
if(handled is false)
return ReplaceStatus.InvalidEntityType;
}
private ReplaceStatus ReplaceLegacyLocalLink(IPublishedSnapshot publishedSnapshot, string href, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl)
{
Match match = LegacyLocalLinkRegex().Match(href);
if (match.Success is false)
{
handleInvalidLink();
return ReplaceStatus.NoMatch;
}
if (UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) is false)
{
return ReplaceStatus.NoMatch;
}
switch (udi.EntityType)
{
case Constants.UdiEntityType.Document:
IPublishedContent? content = publishedSnapshot.Content?.GetById(udi);
IApiContentRoute? route = content != null
? _apiContentRouteBuilder.Build(content)
: null;
if (route != null)
{
handleContentRoute(route);
return ReplaceStatus.Success;
}
break;
case Constants.UdiEntityType.Media:
IPublishedContent? media = publishedSnapshot.Media?.GetById(udi);
if (media != null)
{
handleMediaUrl(_apiMediaUrlProvider.GetUrl(media));
return ReplaceStatus.Success;
}
break;
}
return ReplaceStatus.InvalidEntityType;
}
protected void ReplaceLocalImages(IPublishedSnapshot publishedSnapshot, string udi, Action<string> handleMediaUrl)
@@ -80,5 +135,15 @@ internal abstract partial class ApiRichTextParserBase
}
[GeneratedRegex("{localLink:(?<udi>umb:.+)}")]
private static partial Regex LegacyLocalLinkRegex();
[GeneratedRegex("{localLink:(?<guid>.+)}")]
private static partial Regex LocalLinkRegex();
private enum ReplaceStatus
{
NoMatch,
Success,
InvalidEntityType
}
}

View File

@@ -84,6 +84,9 @@ public class UmbracoPlan : MigrationPlan
// we need to re-run this migration, as it was flawed for V14 RC3 (the migration can run twice without any issues)
To<V_14_0_0.AddEditorUiToDataType>("{6FB5CA9E-C823-473B-A14C-FE760D75943C}");
To<V_14_0_0.CleanUpDataTypeConfigurations>("{827360CA-0855-42A5-8F86-A51F168CB559}");
To<V_14_0_0.MigrateRichTextConfiguration>("{FEF2DAF4-5408-4636-BB0E-B8798DF8F095}");
// To 14.1.0
To<V_14_1_0.MigrateRichTextConfiguration>("{FEF2DAF4-5408-4636-BB0E-B8798DF8F095}");
To<V_14_1_0.MigrateOldRichTextSeedConfiguration>("{A385C5DF-48DC-46B4-A742-D5BB846483BC}");
}
}

View File

@@ -0,0 +1,38 @@
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_1_0;
public class MigrateOldRichTextSeedConfiguration : MigrationBase
{
private const string OldSeedValue =
"{\"value\":\",code,undo,redo,cut,copy,mcepasteword,stylepicker,bold,italic,bullist,numlist,outdent,indent,mcelink,unlink,mceinsertanchor,mceimage,umbracomacro,mceinserttable,umbracoembed,mcecharmap,|1|1,2,3,|0|500,400|1049,|true|\"}";
private const string NewDefaultValue =
"{\"toolbar\":[\"styles\",\"bold\",\"italic\",\"alignleft\",\"aligncenter\",\"alignright\",\"bullist\",\"numlist\",\"outdent\",\"indent\",\"sourcecode\",\"link\",\"umbmediapicker\",\"umbembeddialog\"],\"mode\":\"Classic\",\"maxImageSize\":500}";
public MigrateOldRichTextSeedConfiguration(IMigrationContext context) : base(context)
{
}
protected override void Migrate()
{
Sql<ISqlContext> sql = Sql()
.Select<DataTypeDto>()
.From<DataTypeDto>()
.Where<DataTypeDto>(x =>
x.EditorAlias.Equals(Constants.PropertyEditors.Aliases.RichText)
&& x.Configuration == OldSeedValue);
List<DataTypeDto> dataTypeDtos = Database.Fetch<DataTypeDto>(sql);
foreach (DataTypeDto dataTypeDto in dataTypeDtos)
{
// Update the configuration
dataTypeDto.Configuration = NewDefaultValue;
Database.Update(dataTypeDto);
}
}
}

View File

@@ -4,7 +4,7 @@ using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_0_0;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_1_0;
public class MigrateRichTextConfiguration : MigrationBase
{

View File

@@ -101,8 +101,9 @@ public sealed class JsonObjectConverter : JsonConverter<object>
JsonTokenType.Number when reader.TryGetInt32(out int i) => i,
JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String when reader.TryGetDateTimeOffset(out DateTimeOffset datetime) => datetime,
JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
JsonTokenType.String => reader.GetString()!,
JsonTokenType.String => reader.GetString(),
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,8 @@
"dependencies": {
},
"devDependencies": {
"@umbraco-cms/backoffice": "file:../Umbraco.Web.UI.Client",
"@umbraco-ui/uui-css": "^1.8.0-rc.0",
"@umbraco-cms/backoffice": "^14.0.0",
"@umbraco-ui/uui-css": "^1.8.0",
"msw": "^2.3.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",

View File

@@ -8,7 +8,7 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.3.0'
const PACKAGE_VERSION = '2.3.1'
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -7,8 +7,8 @@
"name": "acceptancetest",
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.7",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.63",
"@umbraco/json-models-builders": "^2.0.9",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.65",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"faker": "^4.1.0",
@@ -132,25 +132,19 @@
}
},
"node_modules/@umbraco/json-models-builders": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.7.tgz",
"integrity": "sha512-roR5A+jzIFN9z1BhogMGOEzSzoR8jOrIYIAevT7EnyS3H3OM0m0uREgvjYCQo0+QMfVws4zq4Ydjx2TIfGYvlQ==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.9.tgz",
"integrity": "sha512-p6LjcE38WsFCvLtRRRVOCuMvris3OXeoueFu0FZBOHk2r7PXiqYCBUls/KbKxqpixzVDAb48RBd1hV7sKPcm5A==",
"dependencies": {
"camelize": "^1.0.1",
"faker": "^6.6.6"
"camelize": "^1.0.1"
}
},
"node_modules/@umbraco/json-models-builders/node_modules/faker": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz",
"integrity": "sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg=="
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "2.0.0-beta.63",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.63.tgz",
"integrity": "sha512-fLXUcWNJupfGKkD6zOGg6WcU5cmqQ6gQkyIyG+UsKSrkgCxK23+N5LrOz2OVp2NZ8GQ8kB5pJ4izvCp+yMMOnA==",
"version": "2.0.0-beta.65",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.65.tgz",
"integrity": "sha512-plSD/4hhVaMl2TItAaBOUQyuy0Qo5rW3EGIF0TvL3a01s6hNoW1DrOCZhWsOOsMTkgf+oScLEsVIBMk0uDLQrg==",
"dependencies": {
"@umbraco/json-models-builders": "2.0.7",
"@umbraco/json-models-builders": "2.0.9",
"camelize": "^1.0.0",
"faker": "^4.1.0",
"form-data": "^4.0.0",

View File

@@ -21,8 +21,8 @@
"wait-on": "^7.2.0"
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.7",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.63",
"@umbraco/json-models-builders": "^2.0.9",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.65",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"faker": "^4.1.0",

View File

@@ -0,0 +1,85 @@
import { ConstantHelper, test, AliasHelper } from '@umbraco/playwright-testhelpers';
import {expect} from "@playwright/test";
const contentName = 'TestContent';
const documentTypeName = 'TestDocumentTypeForContent';
const dataTypeName = 'Checkbox list';
test.beforeEach(async ({umbracoApi, umbracoUi}) => {
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoUi.goToBackOffice();
});
test.afterEach(async ({umbracoApi}) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can create content with the checkbox list data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveButton();
// Assert
await umbracoUi.content.isSuccessNotificationVisible();
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values).toEqual([]);
});
test('can publish content with the checkbox list data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveAndPublishButton();
// Assert
await umbracoUi.content.doesSuccessNotificationsHaveCount(2);
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values).toEqual([]);
});
test('can create content with the custom approved color data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const customDataTypeName = 'CustomCheckboxList';
const optionValues = ['testOption1', 'testOption2'];
const customDataTypeId = await umbracoApi.dataType.createCheckboxListDataType(customDataTypeName, optionValues);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.chooseCheckboxListOption(optionValues[0]);
await umbracoUi.content.clickSaveAndPublishButton();
// Assert
await umbracoUi.content.doesSuccessNotificationsHaveCount(2);
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values[0].alias).toEqual(AliasHelper.toAlias(customDataTypeName));
expect(contentData.values[0].value).toEqual([optionValues[0]]);
// Clean
await umbracoApi.dataType.ensureNameNotExists(customDataTypeName);
});

View File

@@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Tests.Common;
using Umbraco.Cms.Tests.UnitTests.TestHelpers.Objects;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Templates;
@@ -21,6 +22,32 @@ public class HtmlLocalLinkParserTests
{
[Test]
public void Returns_Udis_From_LocalLinks()
{
var input = @"<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a type=""document"" href=""/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}"" title=""other page"">other page</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a type=""media"" href=""/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}"" title=""media"">media</a>
</p>";
var umbracoContextAccessor = new TestUmbracoContextAccessor();
var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of<IPublishedUrlProvider>());
var result = parser.FindUdisFromLocalLinks(input).ToList();
Assert.Multiple(() =>
{
Assert.AreEqual(2, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"), result);
Assert.Contains(UdiParser.Parse("umb://media/7e21a725-b905-4c5f-86dc-8c41ec116e39"), result);
});
}
// todo remove at some point and the implementation.
[Test]
public void Returns_Udis_From_Legacy_LocalLinks()
{
var input = @"<p>
<div>
@@ -36,12 +63,59 @@ public class HtmlLocalLinkParserTests
var result = parser.FindUdisFromLocalLinks(input).ToList();
Assert.AreEqual(2, result.Count);
Assert.AreEqual(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result[0]);
Assert.AreEqual(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result[1]);
Assert.Multiple(() =>
{
Assert.AreEqual(2, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result);
Assert.Contains(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result);
});
}
// todo remove at some point and the implementation.
[Test]
public void Returns_Udis_From_Legacy_And_Current_LocalLinks()
{
var input = @"<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a href=""{locallink:umb://document/C093961595094900AAF9170DDE6AD442}"">hello</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a href=""{locallink:umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2}"">hello</a>
</p>
<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a type=""document"" href=""/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}"" title=""other page"">other page</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a type=""media"" href=""/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}"" title=""media"">media</a>
</p>";
var umbracoContextAccessor = new TestUmbracoContextAccessor();
var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of<IPublishedUrlProvider>());
var result = parser.FindUdisFromLocalLinks(input).ToList();
Assert.Multiple(() =>
{
Assert.AreEqual(4, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"), result);
Assert.Contains(UdiParser.Parse("umb://media/7e21a725-b905-4c5f-86dc-8c41ec116e39"), result);
Assert.Contains(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result);
Assert.Contains(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result);
});
}
[TestCase("", "")]
// current
[TestCase(
"<a type=\"document\" href=\"/{localLink:9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" title=\"world\">world</a>",
"<a type=\"document\" href=\"/my-test-url\" title=\"world\">world</a>")]
[TestCase(
"<a type=\"media\" href=\"/{localLink:9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" title=\"world\">world</a>",
"<a type=\"media\" href=\"/media/1001/my-image.jpg\" title=\"world\">world</a>")]
// legacy
[TestCase(
"hello href=\"{localLink:1234}\" world ",
"hello href=\"/my-test-url\" world ")]

View File

@@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Infrastructure.DeliveryApi;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.DeliveryApi;
[TestFixture]
public class ApiRichTextMarkupParserTests
{
private Mock<IApiContentRouteBuilder> _apiContentRouteBuilder;
private Mock<IApiMediaUrlProvider> _apiMediaUrlProvider;
private Mock<IPublishedSnapshotAccessor> _publishedSnapshotAccessor;
[Test]
public void Can_Parse_Legacy_LocalLinks()
{
var key1 = Guid.Parse("a1c5d649977f4ea59b1cb26055f3eed3");
var data1 = new MockData()
.WithKey(key1)
.WithRoutePath("/inline/")
.WithRouteStartPath("inline");
var mockData = new Dictionary<Guid, MockData>
{
{ key1, data1 },
};
var parser = BuildDefaultSut(mockData);
var legacyHtml =
"<p><a href=\"/{localLink:umb://document/a1c5d649977f4ea59b1cb26055f3eed3}\" title=\"Inline\">link </a>to another page</p>";
var expectedOutput =
"<p><a href=\"/inline/\" title=\"Inline\" data-start-item-path=\"inline\" data-start-item-id=\"a1c5d649-977f-4ea5-9b1c-b26055f3eed3\">link </a>to another page</p>";
var parsedHtml = parser.Parse(legacyHtml);
Assert.AreEqual(expectedOutput, parsedHtml);
}
[Test]
public void Can_Parse_LocalLinks()
{
var key1 = Guid.Parse("eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f");
var data1 = new MockData()
.WithKey(key1)
.WithRoutePath("/self/")
.WithRouteStartPath("self");
var key2 = Guid.Parse("cc143afe-4cbf-46e5-b399-c9f451384373");
var data2 = new MockData()
.WithKey(key2)
.WithRoutePath("/other/")
.WithRouteStartPath("other");
var mockData = new Dictionary<Guid, MockData>
{
{ key1, data1 },
{ key2, data2 },
};
var parser = BuildDefaultSut(mockData);
var html =
@"<p>Rich text outside of the blocks with a link to <a type=""document"" href=""/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}"" title=""itself"">itself</a><br><br></p>
<p>and to the <a type=""document"" href=""/{localLink:cc143afe-4cbf-46e5-b399-c9f451384373}"" title=""other page"">other page</a></p>";
var expectedOutput =
@"<p>Rich text outside of the blocks with a link to <a type=""document"" href=""/self/"" title=""itself"" data-start-item-path=""self"" data-start-item-id=""eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"">itself</a><br><br></p>
<p>and to the <a type=""document"" href=""/other/"" title=""other page"" data-start-item-path=""other"" data-start-item-id=""cc143afe-4cbf-46e5-b399-c9f451384373"">other page</a></p>";
var parsedHtml = parser.Parse(html);
Assert.AreEqual(expectedOutput, parsedHtml);
}
[Test]
public void Can_Parse_Legacy_LocalImages()
{
var key1 = Guid.Parse("395bdc0e8f4d4ad4af7f3a3f6265651e");
var data1 = new MockData()
.WithKey(key1)
.WithMediaUrl("https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp");
var mockData = new Dictionary<Guid, MockData>
{
{ key1, data1 },
};
var parser = BuildDefaultSut(mockData);
var legacyHtml =
@"<p>An image</p>\n<p><img src=""/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?rmode=max&amp;width=500&amp;height=500"" alt="""" width=""500"" height=""500"" data-udi=""umb://media/395bdc0e8f4d4ad4af7f3a3f6265651e""></p>";
var expectedOutput =
@"<p>An image</p>\n<p><img src=""https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?rmode=max&amp;width=500&amp;height=500"" alt="""" width=""500"" height=""500""></p>";
var parsedHtml = parser.Parse(legacyHtml);
Assert.AreEqual(expectedOutput, parsedHtml);
}
private ApiRichTextMarkupParser BuildDefaultSut(Dictionary<Guid, MockData> mockData)
{
var contentCacheMock = new Mock<IPublishedContentCache>();
contentCacheMock.Setup(cc => cc.GetById(It.IsAny<bool>(), It.IsAny<Guid>()))
.Returns<bool, Guid>((preview, key) => mockData[key].PublishedContent);
contentCacheMock.Setup(cc => cc.GetById(It.IsAny<Guid>()))
.Returns<Guid>(key => mockData[key].PublishedContent);
contentCacheMock.Setup(cc => cc.GetById(It.IsAny<bool>(), It.IsAny<Udi>()))
.Returns<bool, Udi>((preview, udi) => mockData[((GuidUdi)udi).Guid].PublishedContent);
contentCacheMock.Setup(cc => cc.GetById(It.IsAny<Udi>()))
.Returns<Udi>(udi => mockData[((GuidUdi)udi).Guid].PublishedContent);
var mediaCacheMock = new Mock<IPublishedMediaCache>();
mediaCacheMock.Setup(cc => cc.GetById(It.IsAny<bool>(), It.IsAny<Guid>()))
.Returns<bool, Guid>((preview, key) => mockData[key].PublishedContent);
mediaCacheMock.Setup(cc => cc.GetById(It.IsAny<Guid>()))
.Returns<Guid>(key => mockData[key].PublishedContent);
mediaCacheMock.Setup(cc => cc.GetById(It.IsAny<bool>(), It.IsAny<Udi>()))
.Returns<bool, Udi>((preview, udi) => mockData[((GuidUdi)udi).Guid].PublishedContent);
mediaCacheMock.Setup(cc => cc.GetById(It.IsAny<Udi>()))
.Returns<Udi>(udi => mockData[((GuidUdi)udi).Guid].PublishedContent);
var snapshotMock = new Mock<IPublishedSnapshot>();
snapshotMock.SetupGet(ss => ss.Content)
.Returns(contentCacheMock.Object);
snapshotMock.SetupGet(ss => ss.Media)
.Returns(mediaCacheMock.Object);
var snapShot = snapshotMock.Object;
_publishedSnapshotAccessor = new Mock<IPublishedSnapshotAccessor>();
_publishedSnapshotAccessor.Setup(psa => psa.TryGetPublishedSnapshot(out snapShot))
.Returns(true);
_apiMediaUrlProvider = new Mock<IApiMediaUrlProvider>();
_apiMediaUrlProvider.Setup(mup => mup.GetUrl(It.IsAny<IPublishedContent>()))
.Returns<IPublishedContent>(ipc => mockData[ipc.Key].MediaUrl);
_apiContentRouteBuilder = new Mock<IApiContentRouteBuilder>();
_apiContentRouteBuilder.Setup(acrb => acrb.Build(It.IsAny<IPublishedContent>(), It.IsAny<string>()))
.Returns<IPublishedContent, string>((content, culture) => mockData[content.Key].ApiContentRoute);
return new ApiRichTextMarkupParser(
_apiContentRouteBuilder.Object,
_apiMediaUrlProvider.Object,
_publishedSnapshotAccessor.Object,
Mock.Of<ILogger<ApiRichTextMarkupParser>>());
}
private class MockData
{
private Mock<IPublishedContent> _publishedContentMock = new Mock<IPublishedContent>();
private Mock<IApiContentRoute> _apiContentRouteMock = new Mock<IApiContentRoute>();
private Mock<IApiContentStartItem> _apiContentStartItem = new Mock<IApiContentStartItem>();
public IPublishedContent PublishedContent => _publishedContentMock.Object;
public IApiContentRoute ApiContentRoute => _apiContentRouteMock.Object;
public string MediaUrl { get; set; } = string.Empty;
public MockData()
{
_apiContentRouteMock.SetupGet(r => r.StartItem).Returns(_apiContentStartItem.Object);
}
public MockData WithKey(Guid key)
{
_publishedContentMock.SetupGet(i => i.Key).Returns(key);
_apiContentStartItem.SetupGet(rsi => rsi.Id).Returns(key);
return this;
}
public MockData WithRoutePath(string path)
{
_apiContentRouteMock.SetupGet(r => r.Path).Returns(path);
return this;
}
public MockData WithRouteStartPath(string path)
{
_apiContentStartItem.SetupGet(rsi => rsi.Path).Returns(path);
return this;
}
public MockData WithMediaUrl(string url)
{
MediaUrl = url;
return this;
}
}
}

View File

@@ -45,7 +45,7 @@ public class BackOfficeAreaRoutesTests
var endpoints = new TestRouteBuilder();
routes.CreateRoutes(endpoints);
Assert.AreEqual(1, endpoints.DataSources.Count);
Assert.AreEqual(2, endpoints.DataSources.Count);
var route = endpoints.DataSources.First();
Assert.AreEqual(2, route.Endpoints.Count);

View File

@@ -140,6 +140,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{20CE9C97
ProjectSection(SolutionItems) = preProject
build\azure-pipelines.yml = build\azure-pipelines.yml
build\nightly-build-trigger.yml = build\nightly-build-trigger.yml
build\templates\backoffice-install.yml = build\templates\backoffice-install.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp-docs", "csharp-docs", "{F2BF84D9-0A14-40AF-A0F3-B9BBBBC16A44}"

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "14.1.0-rc",
"version": "14.1.0-rc2",
"assemblyVersion": {
"precision": "build"
},