diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 6557cd17dc..7d20039897 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -51,6 +51,7 @@ public static class UmbracoBuilderExtensions }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs index da8e9eeea0..cf29e73864 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; @@ -16,12 +16,12 @@ internal sealed class ContextualizeFromAcceptHeadersAttribute : TypeFilterAttrib private sealed class LocalizeFromAcceptLanguageHeaderAttributeFilter : IActionFilter { private readonly IRequestCultureService _requestCultureService; - private readonly IRequestSegmmentService _requestSegmentService; + private readonly IRequestSegmentService _requestSegmentService; private readonly IVariationContextAccessor _variationContextAccessor; public LocalizeFromAcceptLanguageHeaderAttributeFilter( IRequestCultureService requestCultureService, - IRequestSegmmentService requestSegmentService, + IRequestSegmentService requestSegmentService, IVariationContextAccessor variationContextAccessor) { _requestCultureService = requestCultureService; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs index c59c2fb56e..b1222fe34d 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs @@ -3,7 +3,7 @@ using Umbraco.Cms.Core.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Services; -internal sealed class RequestSegmentService : RequestHeaderHandler, IRequestSegmmentService +internal sealed class RequestSegmentService : RequestHeaderHandler, IRequestSegmentService, IRequestSegmmentService { public RequestSegmentService(IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/AvailableSegmentsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/AvailableSegmentsController.cs new file mode 100644 index 0000000000..bb83d578c9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/AvailableSegmentsController.cs @@ -0,0 +1,85 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Segment; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[Obsolete("This controller is temporary and will be removed in a future release (planned for v20). A more permanent solution will follow.")] +[ApiVersion("1.0")] +public class AvailableSegmentsController : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly ISegmentService _segmentService; + private readonly IUmbracoMapper _umbracoMapper; + + public AvailableSegmentsController( + IAuthorizationService authorizationService, + ISegmentService segmentService, + IUmbracoMapper umbracoMapper) + { + _authorizationService = authorizationService; + _segmentService = segmentService; + _umbracoMapper = umbracoMapper; + } + + [HttpGet("{id:guid}/available-segment-options")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task GetAvailableSegmentOptions( + Guid id, + CancellationToken cancellationToken, + int skip = 0, + int take = 100) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt?, SegmentOperationStatus> pagedAttempt = + await _segmentService.GetPagedSegmentsForDocumentAsync(id, skip, take); + + if (pagedAttempt.Success is false) + { + return MapFailure(pagedAttempt.Status); + } + + var viewModel = new PagedViewModel + { + Items = _umbracoMapper.MapEnumerable(pagedAttempt.Result!.Items), + Total = pagedAttempt.Result!.Total, + }; + + return Ok(viewModel); + } + + private IActionResult MapFailure(SegmentOperationStatus status) + => OperationStatusResult( + status, + problemDetailsBuilder => status switch + { + _ => StatusCode( + StatusCodes.Status500InternalServerError, + problemDetailsBuilder + .WithTitle("Unknown segment operation status.") + .Build()), + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Segment/SegmentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Segment/SegmentMapDefinition.cs index 903d460119..e1beb7c846 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Segment/SegmentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Segment/SegmentMapDefinition.cs @@ -5,12 +5,15 @@ namespace Umbraco.Cms.Api.Management.Mapping.Segment; public class SegmentMapDefinition : IMapDefinition { - public void DefineMaps(IUmbracoMapper mapper) => mapper.Define((_, _) => new SegmentResponseModel { Name = string.Empty, Alias = string.Empty }, Map); + public void DefineMaps(IUmbracoMapper mapper) => mapper.Define( + (_, _) => new SegmentResponseModel { Name = string.Empty, Alias = string.Empty, Cultures = null }, + Map); // Umbraco.Code.MapAll private static void Map(Core.Models.Segment source, SegmentResponseModel target, MapperContext context) { target.Name = source.Name; target.Alias = source.Alias; + target.Cultures = source.Cultures; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Segment/SegmentResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Segment/SegmentResponseModel.cs index 4932104206..fe93827b48 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Segment/SegmentResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Segment/SegmentResponseModel.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - namespace Umbraco.Cms.Api.Management.ViewModels.Segment; public class SegmentResponseModel @@ -7,4 +5,7 @@ public class SegmentResponseModel public required string Name { get; set; } = string.Empty; public required string Alias { get; set; } = string.Empty; + + [Obsolete("This property is temporary and will be removed in a future release (planned for v20). A more permanent solution will follow.")] + public IEnumerable? Cultures { get; set; } = null; } diff --git a/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs b/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs index c7c3c3425f..7eb11c3583 100644 --- a/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs +++ b/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs @@ -1,5 +1,14 @@ -namespace Umbraco.Cms.Core.DeliveryApi; +namespace Umbraco.Cms.Core.DeliveryApi; +public interface IRequestSegmentService +{ + /// + /// Gets the requested segment from the "Accept-Segment" header, if present. + /// + string? GetRequestedSegment(); +} + +[Obsolete("This interface is misspelled and will be removed in Umbraco 18. Please use the correct one IRequestSegmentService")] public interface IRequestSegmmentService { /// diff --git a/src/Umbraco.Core/Models/Segment.cs b/src/Umbraco.Core/Models/Segment.cs index 117b886d64..2bb02d90f2 100644 --- a/src/Umbraco.Core/Models/Segment.cs +++ b/src/Umbraco.Core/Models/Segment.cs @@ -5,4 +5,7 @@ public class Segment public required string Name { get; set; } public required string Alias { get; set; } + + [Obsolete("This property is temporary and will be removed in a future release (planned for v20). A more permanent solution will follow.")] + public IEnumerable? Cultures { get; set; } = null; } diff --git a/src/Umbraco.Core/Services/ISegmentService.cs b/src/Umbraco.Core/Services/ISegmentService.cs index f5af487a73..c31cbf9f5e 100644 --- a/src/Umbraco.Core/Services/ISegmentService.cs +++ b/src/Umbraco.Core/Services/ISegmentService.cs @@ -5,5 +5,27 @@ namespace Umbraco.Cms.Core.Services; public interface ISegmentService { - Task?, SegmentOperationStatus>> GetPagedSegmentsAsync(int skip = 0, int take = 100); + /// + /// Gets a paged list of segments. + /// + /// The number of items to skip. + /// The number of items to take. + /// The paged list of segments. + Task?, SegmentOperationStatus>> GetPagedSegmentsAsync( + int skip = 0, + int take = 100); + + /// + /// Gets a paged list of segments for a specific document. + /// + /// The document unique identifier. + /// The number of items to skip. + /// The number of items to take. + /// The paged list of segments. + [Obsolete("This method is temporary and will be removed in a future release (planned for v20). A more permanent solution will follow.")] + Task?, SegmentOperationStatus>> GetPagedSegmentsForDocumentAsync( + Guid id, + int skip = 0, + int take = 100) + => GetPagedSegmentsAsync(skip, take); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs index 3c1e7cd683..50d06b725b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs @@ -1,8 +1,16 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Data.SqlTypes; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.PropertyEditors.Validators; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -25,8 +33,91 @@ public class DateTimePropertyEditor : DataEditor /// protected override IDataValueEditor CreateValueEditor() { - IDataValueEditor editor = base.CreateValueEditor(); + DateTimePropertyValueEditor editor = DataValueEditorFactory.Create(Attribute!); editor.Validators.Add(new DateTimeValidator()); return editor; } + + /// + /// Provides a value editor for the datetime property editor. + /// + internal sealed class DateTimePropertyValueEditor : DataValueEditor + { + /// + /// The key used to retrieve the date format from the data type configuration. + /// + internal const string DateTypeConfigurationFormatKey = "format"; + + /// + /// Initializes a new instance of the class. + /// + public DateTimePropertyValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } + + /// + public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) + { + if (editorValue.Value is null) + { + return base.FromEditor(editorValue, currentValue); + } + + if (TryGetConfiguredDateTimeFormat(editorValue, out string? format) is false) + { + return base.FromEditor(editorValue, currentValue); + } + + if (IsTimeOnlyFormat(format) is false) + { + return base.FromEditor(editorValue, currentValue); + } + + // We have a time-only format, so we need to ensure the date part is valid for SQL Server, so we can persist + // without error. + // If we have a date part that is less than the minimum date, we need to adjust it to be the minimum date. + Attempt dateConvertAttempt = editorValue.Value.TryConvertTo(typeof(DateTime?)); + if (dateConvertAttempt.Success is false || dateConvertAttempt.Result is null) + { + return base.FromEditor(editorValue, currentValue); + } + + var dateTimeValue = (DateTime)dateConvertAttempt.Result; + int yearValue = dateTimeValue.Year > SqlDateTime.MinValue.Value.Year + ? dateTimeValue.Year + : SqlDateTime.MinValue.Value.Year; + return new DateTime(yearValue, dateTimeValue.Month, dateTimeValue.Day, dateTimeValue.Hour, dateTimeValue.Minute, dateTimeValue.Second); + } + + private static bool TryGetConfiguredDateTimeFormat(ContentPropertyData editorValue, [NotNullWhen(true)] out string? format) + { + if (editorValue.DataTypeConfiguration is not Dictionary dataTypeConfigurationDictionary) + { + format = null; + return false; + } + + KeyValuePair keyValuePair = dataTypeConfigurationDictionary + .FirstOrDefault(kvp => kvp.Key is "format"); + format = keyValuePair.Value as string; + return string.IsNullOrWhiteSpace(format) is false; + } + + private static bool IsTimeOnlyFormat(string format) + { + DateTime testDate = DateTime.UtcNow; + var testDateFormatted = testDate.ToString(format); + if (DateTime.TryParseExact(testDateFormatted, format, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out DateTime parsedDate) is false) + { + return false; + } + + return parsedDate.Year == 1 && parsedDate.Month == 1 && parsedDate.Day == 1; + } + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index a74ea4ded9..e077081df6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -98,6 +98,10 @@ export class UmbPropertyEditorUIDropdownElement } else { this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-dropdown-list')!); } + + if (!this.mandatory && !this._multiple) { + this._options.unshift({ name: '', value: '', selected: false, invalid: false }); + } } #onChange(event: CustomEvent & { target: UmbInputDropdownListElement }) { diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 08dac24f0f..478655791d 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -7,7 +7,7 @@ "name": "acceptancetest", "hasInstallScript": true, "dependencies": { - "@umbraco/json-models-builders": "^2.0.37", + "@umbraco/json-models-builders": "^2.0.38", "@umbraco/playwright-testhelpers": "^16.0.42", "camelize": "^1.0.0", "dotenv": "^16.3.1", diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 32076a6012..adda209951 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,7 +21,7 @@ "typescript": "^4.8.3" }, "dependencies": { - "@umbraco/json-models-builders": "^2.0.37", + "@umbraco/json-models-builders": "^2.0.38", "@umbraco/playwright-testhelpers": "^16.0.42", "camelize": "^1.0.0", "dotenv": "^16.3.1", diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts index 927e2cded4..7a07afbd78 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts @@ -172,5 +172,4 @@ test('can remove content picker in the content', async ({umbracoApi, umbracoUi}) await umbracoUi.content.isSuccessStateVisibleForSaveButton(); const contentData = await umbracoApi.document.getByName(contentName); expect(contentData.values).toEqual([]); -}); - +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultiNodeTreePickerDataType.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultiNodeTreePickerDataType.spec.ts new file mode 100644 index 0000000000..d0e972edcf --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultiNodeTreePickerDataType.spec.ts @@ -0,0 +1,183 @@ +import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const customDataTypeName = 'CustomMultiNodeTreePicker'; + +test.beforeEach(async ({umbracoApi}) => { + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.document.ensureNameNotExists(contentName); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can create content with content picker with allowed types', async ({umbracoApi, umbracoUi}) => { + // Arrange + const allowedContentPickerDocumentTypeName = 'ContentPickerDocumentType'; + const allowedContentPickerName = 'Test Content Picker'; + const notAllowedContentPickerName = 'Not Allowed Test Content Picker'; + const allowedContentPickerDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentTypeWithAllowAsRoot(allowedContentPickerDocumentTypeName); + const allowedContentPickerId = await umbracoApi.document.createDefaultDocument(allowedContentPickerName, allowedContentPickerDocumentTypeId); + // Create a custom content picker with predefined allowed types + const customDataTypeId = await umbracoApi.dataType.createMultiNodeTreePickerDataTypeWithAllowedTypes(customDataTypeName, allowedContentPickerDocumentTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); + await umbracoApi.document.createDefaultDocument(notAllowedContentPickerName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.clickActionsMenuAtRoot(); + await umbracoUi.content.clickCreateActionMenuOption(); + await umbracoUi.content.chooseDocumentType(documentTypeName); + await umbracoUi.content.enterContentName(contentName); + await umbracoUi.content.clickChooseButton(); + await umbracoUi.content.isModalMenuItemWithNameDisabled(notAllowedContentPickerName); + await umbracoUi.content.selectLinkByName(allowedContentPickerName); + await umbracoUi.content.clickChooseModalButton(); + await umbracoUi.content.clickSaveAndPublishButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.published); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value[0]['unique']).toEqual(allowedContentPickerId); + expect(contentData.values[0].value[0]['type']).toEqual('document'); + + // Clean + await umbracoApi.document.ensureNameNotExists(allowedContentPickerName); + await umbracoApi.document.ensureNameNotExists(notAllowedContentPickerName); +}); + +test('can search and see only allowed content types', async ({umbracoApi, umbracoUi}) => { + // Arrange + const allowedContentPickerDocumentTypeName = 'ContentPickerDocumentType'; + const allowedContentPickerName = 'Test Content Picker'; + const notAllowedContentPickerName = 'Not Allowed Test Content Picker'; + const allowedContentPickerDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentTypeWithAllowAsRoot(allowedContentPickerDocumentTypeName); + const allowedContentPickerId = await umbracoApi.document.createDefaultDocument(allowedContentPickerName, allowedContentPickerDocumentTypeId); + // Create a content with custom content picker with predefined allowed types + const customDataTypeId = await umbracoApi.dataType.createMultiNodeTreePickerDataTypeWithAllowedTypes(customDataTypeName, allowedContentPickerDocumentTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoApi.document.createDefaultDocument(notAllowedContentPickerName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickChooseButton(); + + // Assert + await umbracoUi.content.isModalMenuItemWithNameVisible(allowedContentPickerName); + await umbracoUi.content.isModalMenuItemWithNameDisabled(notAllowedContentPickerName); + await umbracoUi.content.enterSearchKeywordInTreePickerModal('Picker'); + await umbracoUi.content.isModalMenuItemWithNameVisible(notAllowedContentPickerName, false); + await umbracoUi.content.clickEntityItemByName(allowedContentPickerName); + await umbracoUi.content.clickChooseModalButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessStateIconVisible(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value[0]['unique']).toEqual(allowedContentPickerId); + expect(contentData.values[0].value[0]['type']).toEqual('document'); + + // Clean + await umbracoApi.document.ensureNameNotExists(allowedContentPickerName); + await umbracoApi.document.ensureNameNotExists(notAllowedContentPickerName); +}); + +test('can search and see only allowed media types', async ({umbracoApi, umbracoUi}) => { + // Arrange + const allowedMediaPickerName = 'Test Image'; + const notAllowedMediaPickerName = 'Test Article'; + const allowedMediaPickerId = await umbracoApi.media.createDefaultMediaWithImage(allowedMediaPickerName); + await umbracoApi.media.createDefaultMediaWithArticle(notAllowedMediaPickerName); + const imageMediaTypeData = await umbracoApi.mediaType.getByName('Image'); + // Create a content with custom tree picker with predefined allowed media types + const customDataTypeId = await umbracoApi.dataType.createMultiNodeTreePickerDataTypeWithAllowedTypes(customDataTypeName, imageMediaTypeData.id, 'media'); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickChooseButton(); + + // Assert + await umbracoUi.content.isMediaCardItemWithNameDisabled(notAllowedMediaPickerName); + await umbracoUi.content.isMediaCardItemWithNameVisible(allowedMediaPickerName); + await umbracoUi.content.enterSearchKeywordInMediaPickerModal('Test'); + await umbracoUi.content.isMediaCardItemWithNameVisible(notAllowedMediaPickerName, false); + await umbracoUi.content.clickMediaWithName(allowedMediaPickerName); + await umbracoUi.content.clickChooseModalButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessStateIconVisible(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value[0]['unique']).toEqual(allowedMediaPickerId); + expect(contentData.values[0].value[0]['type']).toEqual('media'); + + // Clean + await umbracoApi.media.ensureNameNotExists(allowedMediaPickerName); + await umbracoApi.media.ensureNameNotExists(notAllowedMediaPickerName); +}); + +test('can search and see only allowed member types', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Not allowed member type + const notAllowedMemberTypeName = 'Not Allowed Member Type'; + const notAllowedMemberTypeId = await umbracoApi.memberType.createDefaultMemberType(notAllowedMemberTypeName); + // Allowed member type + const allowedMemberTypeData = await umbracoApi.memberType.getByName('Member'); + // Allowed member + const allowedTestMember = { + name : 'Allowed Test Member', + username : 'allowedTestMember', + email : 'allowedTestMember@acceptance.test', + password : '0123456789', + }; + const allowedTestMemberId = await umbracoApi.member.createDefaultMember(allowedTestMember.name, allowedMemberTypeData.id, allowedTestMember.email, allowedTestMember.username, allowedTestMember.password); + // Not allowed member + const notAllowedTestMember = { + name : 'Not Allowed Test Member', + username : 'notAllowedTestMember', + email : 'notAllowedTestMember@acceptance.test', + password : '0123456789', + }; + await umbracoApi.member.createDefaultMember(notAllowedTestMember.name, notAllowedMemberTypeId, notAllowedTestMember.email, notAllowedTestMember.username, notAllowedTestMember.password); + // Create a content with custom tree picker with predefined allowed member types + const customDataTypeId = await umbracoApi.dataType.createMultiNodeTreePickerDataTypeWithAllowedTypes(customDataTypeName, allowedMemberTypeData.id, 'member'); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickChooseButton(); + + // Assert + await umbracoUi.content.isModalMenuItemWithNameVisible(allowedTestMember.name); + await umbracoUi.content.isModalMenuItemWithNameDisabled(notAllowedTestMember.name); + await umbracoUi.content.enterSearchKeywordInMemberPickerModal('Allowed Test Member'); + await umbracoUi.content.isModalMenuItemWithNameVisible(notAllowedTestMember.name, false); + await umbracoUi.content.clickEntityItemByName(allowedTestMember.name); + await umbracoUi.content.clickChooseModalButton(); + await umbracoUi.content.clickSaveButton(); + await umbracoUi.content.isSuccessStateIconVisible(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value[0]['unique']).toEqual(allowedTestMemberId); + expect(contentData.values[0].value[0]['type']).toEqual('member'); + + // Clean + await umbracoApi.member.ensureNameNotExists(allowedTestMember.name); + await umbracoApi.member.ensureNameNotExists(notAllowedTestMember.name); + await umbracoApi.memberType.ensureNameNotExists(notAllowedMemberTypeName); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs new file mode 100644 index 0000000000..ba6adce308 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs @@ -0,0 +1,41 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +public class DateTimePropertyEditorTests +{ + // Various time formats with years below the minimum, so we expect to increase the date to the minimum supported by SQL Server. + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "hh:mm")] + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "HH:mm")] + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "hh mm")] + [TestCase("10/10/1000 10:00", "10/10/1753 10:00", "hh:mm:ss")] + [TestCase("10/10/1000 10:00", "10/10/1753 10:00", "hh-mm-ss")] + + // Time format with year above the minimum, so we expect to not convert. + [TestCase("01/01/2000 10:00", "01/01/2000 10:00", "HH:mm")] + + // Date formats, so we don't convert even if the year is below the minimum. + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "dd-MM-yyyy hh:mm")] + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "dd-MM-yyyy")] + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "yyyy-MM-d")] + public void Time_Only_Format_Ensures_DateTime_Can_Be_Persisted(DateTime actualDateTime, DateTime expectedDateTime, string format) + { + var dateTimePropertyEditor = new DateTimePropertyEditor.DateTimePropertyValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute("Alias") { ValueType = ValueTypes.DateTime.ToString() }); + + Dictionary dictionary = new Dictionary { { DateTimePropertyEditor.DateTimePropertyValueEditor.DateTypeConfigurationFormatKey, format } }; + ContentPropertyData propertyData = new ContentPropertyData(actualDateTime, dictionary); + var value = (DateTime)dateTimePropertyEditor.FromEditor(propertyData, null); + + Assert.AreEqual(expectedDateTime, value); + } +}