diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index 2d2ec674b9..28c9ad1427 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -28,6 +28,7 @@ public class ContentSettings internal const bool StaticDisableUnpublishWhenReferenced = false; internal const bool StaticAllowEditInvariantFromNonDefault = false; internal const bool StaticShowDomainWarnings = true; + internal const bool StaticShowUnroutableContentWarnings = true; /// /// Gets or sets a value for the content notification settings. @@ -141,4 +142,10 @@ public class ContentSettings /// [DefaultValue(StaticShowDomainWarnings)] public bool ShowDomainWarnings { get; set; } = StaticShowDomainWarnings; + + /// + /// Gets or sets a value indicating whether to show unroutable content warnings. + /// + [DefaultValue(StaticShowUnroutableContentWarnings)] + public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings; } diff --git a/src/Umbraco.Core/Events/AddUnroutableContentWarningsWhenPublishingNotificationHandler.cs b/src/Umbraco.Core/Events/AddUnroutableContentWarningsWhenPublishingNotificationHandler.cs new file mode 100644 index 0000000000..ad457d287c --- /dev/null +++ b/src/Umbraco.Core/Events/AddUnroutableContentWarningsWhenPublishingNotificationHandler.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Web; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Events; + +public class AddUnroutableContentWarningsWhenPublishingNotificationHandler : INotificationAsyncHandler +{ + private readonly IPublishedRouter _publishedRouter; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ILanguageService _languageService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IContentService _contentService; + private readonly IVariationContextAccessor _variationContextAccessor; + private readonly ILoggerFactory _loggerFactory; + private readonly UriUtility _uriUtility; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly IPublishedContentCache _publishedContentCache; + private readonly IDocumentNavigationQueryService _navigationQueryService; + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly ContentSettings _contentSettings; + + public AddUnroutableContentWarningsWhenPublishingNotificationHandler( + IPublishedRouter publishedRouter, + IUmbracoContextAccessor umbracoContextAccessor, + ILanguageService languageService, + ILocalizedTextService localizedTextService, + IContentService contentService, + IVariationContextAccessor variationContextAccessor, + ILoggerFactory loggerFactory, + UriUtility uriUtility, + IPublishedUrlProvider publishedUrlProvider, + IPublishedContentCache publishedContentCache, + IDocumentNavigationQueryService navigationQueryService, + IEventMessagesFactory eventMessagesFactory, + IOptions contentSettings) + { + _publishedRouter = publishedRouter; + _umbracoContextAccessor = umbracoContextAccessor; + _languageService = languageService; + _localizedTextService = localizedTextService; + _contentService = contentService; + _variationContextAccessor = variationContextAccessor; + _loggerFactory = loggerFactory; + _uriUtility = uriUtility; + _publishedUrlProvider = publishedUrlProvider; + _publishedContentCache = publishedContentCache; + _navigationQueryService = navigationQueryService; + _eventMessagesFactory = eventMessagesFactory; + _contentSettings = contentSettings.Value; + } + + public async Task HandleAsync(ContentPublishedNotification notification, CancellationToken cancellationToken) + { + if (_contentSettings.ShowUnroutableContentWarnings is false) + { + return; + } + + foreach (IContent content in notification.PublishedEntities) + { + string[]? successfulCultures; + if (content.ContentType.VariesByCulture() is false) + { + // successfulCultures will be null here - change it to a wildcard and utilize this below + successfulCultures = ["*"]; + } + else + { + successfulCultures = content.PublishedCultures.ToArray(); + } + + if (successfulCultures?.Any() is not true) + { + return; + } + + if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)) + { + return; + } + + UrlInfo[] urls = (await content.GetContentUrlsAsync( + _publishedRouter, + umbracoContext, + _languageService, + _localizedTextService, + _contentService, + _variationContextAccessor, + _loggerFactory.CreateLogger(), + _uriUtility, + _publishedUrlProvider, + _publishedContentCache, + _navigationQueryService)).ToArray(); + + + EventMessages eventMessages = _eventMessagesFactory.Get(); + foreach (var culture in successfulCultures) + { + if (urls.Where(u => u.Culture == culture || culture == "*").All(u => u.IsUrl is false)) + { + eventMessages.Add(new EventMessage("Content published", "The document does not have a URL, possibly due to a naming collision with another document. More details can be found under Info.", EventMessageType.Warning)); + + // only add one warning here, even though there might actually be more + break; + } + } + } + } +} diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index f7eeb1a1b4..3818bc2776 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -145,8 +145,10 @@ public class NewDefaultUrlProvider : IUrlProvider private string GetLegacyRouteFormatById(Guid key, string? culture) { + var isDraft = _umbracoContextAccessor.GetRequiredUmbracoContext().InPreviewMode; - return _documentUrlService.GetLegacyRouteFormat(key, culture, _umbracoContextAccessor.GetRequiredUmbracoContext().InPreviewMode); + + return _documentUrlService.GetLegacyRouteFormat(key, culture, isDraft); } @@ -163,9 +165,22 @@ public class NewDefaultUrlProvider : IUrlProvider throw new ArgumentException("Current URL must be absolute.", nameof(current)); } + // This might seem to be some code duplication, as we do the same check in GetLegacyRouteFormat + // but this is strictly neccesary, as if we're coming from a published notification + // this document will still not always be in the memory cache. And thus we have to hit the DB + // We have the published content now, so we can check if the culture is published, and thus avoid the DB hit. + string route; + var isDraft = _umbracoContextAccessor.GetRequiredUmbracoContext().InPreviewMode; + if(isDraft is false && string.IsNullOrWhiteSpace(culture) is false && content.Cultures.Any() && content.IsInvariantOrHasCulture(culture) is false) + { + route = "#"; + } + else + { + route = GetLegacyRouteFormatById(content.Key, culture); + } // will not use cache if previewing - var route = GetLegacyRouteFormatById(content.Key, culture); return GetUrlFromRoute(route, content.Id, current, mode, culture); } diff --git a/src/Umbraco.Core/Services/ContentTypeServiceBaseOfTRepositoryTItemTService.cs b/src/Umbraco.Core/Services/ContentTypeServiceBaseOfTRepositoryTItemTService.cs index d458a17fbb..74fe09f8c8 100644 --- a/src/Umbraco.Core/Services/ContentTypeServiceBaseOfTRepositoryTItemTService.cs +++ b/src/Umbraco.Core/Services/ContentTypeServiceBaseOfTRepositoryTItemTService.cs @@ -1171,10 +1171,14 @@ public abstract class ContentTypeServiceBase : ContentTypeSe } else { - TItem[] allowedChildren = GetMany(parent.AllowedContentTypes.Select(x => x.Key)).ToArray(); + // Get the sorted keys. Whilst we can't guarantee the order that comes back from GetMany, we can use + // this to sort the resulting list of allowed children. + Guid[] sortedKeys = parent.AllowedContentTypes.OrderBy(x => x.SortOrder).Select(x => x.Key).ToArray(); + + TItem[] allowedChildren = GetMany(sortedKeys).ToArray(); result = new PagedModel { - Items = allowedChildren.Take(take).Skip(skip), + Items = allowedChildren.OrderBy(x => sortedKeys.IndexOf(x.Key)).Take(take).Skip(skip), Total = allowedChildren.Length, }; } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 50ae0d7deb..bd101ad37b 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -411,8 +411,8 @@ public static partial class UmbracoBuilderExtensions .AddNotificationHandler(); // Handlers for publish warnings - builder - .AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationAsyncHandler(); // Handlers for save warnings builder diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts index 446e123028..6c885e274f 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts @@ -1,4 +1,4 @@ -import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; +import { UMB_APP_CONTEXT } from './app.context.js'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/route.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route.context.ts index 6ba9b8846e..a5ed6a86c7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/route.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route.context.ts @@ -37,6 +37,13 @@ export class UmbRouteContext extends UmbContextBase { }); } + getBasePath() { + return this.#basePath.getValue(); + } + getActivePath() { + return this.getBasePath() + '/' + this.#activeLocalPath; + } + public registerModal(registration: UmbModalRouteRegistration) { this.#modalRegistrations.push(registration); this.#createNewUrlBuilder(registration); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index d9f922996a..5c59f06a66 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -3,7 +3,7 @@ import { createExtensionElement, UmbExtensionsManifestInitializer } from '@umbra import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { ManifestWorkspaceView } from '@umbraco-cms/backoffice/workspace'; +import { UMB_WORKSPACE_VIEW_PATH_PATTERN, type ManifestWorkspaceView } from '@umbraco-cms/backoffice/workspace'; import type { UmbRoute, UmbRouterSlotInitEvent, UmbRouterSlotChangeEvent } from '@umbraco-cms/backoffice/router'; /** @@ -62,7 +62,7 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { if (this._workspaceViews.length > 0) { newRoutes = this._workspaceViews.map((manifest) => { return { - path: `view/${manifest.meta.pathname}`, + path: UMB_WORKSPACE_VIEW_PATH_PATTERN.generateLocal({ viewPathname: manifest.meta.pathname }), component: () => createExtensionElement(manifest), setup: (component) => { if (component) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/paths.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/paths.ts index da4f96e16a..8ac71ae068 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/paths.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/paths.ts @@ -5,3 +5,8 @@ export const UMB_WORKSPACE_PATH_PATTERN = new UmbPathPattern< { entityType: string }, typeof UMB_SECTION_PATH_PATTERN.ABSOLUTE_PARAMS >('workspace/:entityType', UMB_SECTION_PATH_PATTERN); + +export const UMB_WORKSPACE_VIEW_PATH_PATTERN = new UmbPathPattern< + { viewPathname: string }, + typeof UMB_WORKSPACE_PATH_PATTERN.ABSOLUTE_PARAMS +>('view/:viewPathname', UMB_WORKSPACE_PATH_PATTERN); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts index c52b7fde80..a091a221b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts @@ -198,7 +198,7 @@ export class UmbInputDocumentTypeElement extends UmbFormControlMixin + ${this.#renderIcon(item)} this.#removeItem(item)} label=${this.localize.term('general_remove')}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts index 54ff9f9b0f..74728a089b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts @@ -4,6 +4,7 @@ import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/propert import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbPropertyValueChangeEvent } from '@umbraco-cms/backoffice/property-editor'; import { generateAlias } from '@umbraco-cms/backoffice/utils'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; export type UmbCrop = { label: string; @@ -20,14 +21,42 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen @query('#label') private _labelInput!: HTMLInputElement; - @property({ attribute: false }) - value: UmbCrop[] = []; + @state() + private _value: Array = []; + + @property({ type: Array }) + public set value(value: Array) { + this._value = value ?? []; + this.#sorter.setModel(this.value); + } + public get value(): Array { + return this._value; + } @state() editCropAlias = ''; #oldInputValue = ''; + #sorter = new UmbSorterController(this, { + getUniqueOfElement: (element: HTMLElement) => { + const unique = element.dataset["alias"]; + return unique; + }, + getUniqueOfModel: (modelEntry: UmbCrop) => { + return modelEntry.alias; + }, + identifier: 'Umb.SorterIdentifier.ImageCrops', + itemSelector: '.crop', + containerSelector: '.crops', + onChange: ({ model }) => { + const oldValue = this._value; + this._value = model; + this.requestUpdate('_value', oldValue); + this.dispatchEvent(new UmbPropertyValueChangeEvent()); + }, + }); + #onRemove(alias: string) { this.value = [...this.value.filter((item) => item.alias !== alias)]; this.dispatchEvent(new UmbPropertyValueChangeEvent()); @@ -163,7 +192,7 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen this.value, (item) => item.alias, (item) => html` - + + ${item.label} (${item.alias}) (${item.width} x ${item.height}px)