diff --git a/.github/BUILD.md b/.github/BUILD.md index f42b7d127087..d0143bfbe202 100644 --- a/.github/BUILD.md +++ b/.github/BUILD.md @@ -37,7 +37,7 @@ In order to work with the Umbraco source code locally, first make sure you have ### Familiarizing yourself with the code -Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. +Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`. @@ -73,13 +73,20 @@ Just be careful not to include this change in your PR. Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`: -``` +```json "BackOfficeHost": "http://localhost:5173", "AuthorizeCallbackPathName": "/oauth_complete", "AuthorizeCallbackLogoutPathName": "/logout", -"AuthorizeCallbackErrorPathName": "/error" +"AuthorizeCallbackErrorPathName": "/error", +"BackOfficeTokenCookie": { + "Enabled": true, + "SameSite": "None" +} ``` +> [!NOTE] +> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately. + Then run Umbraco from the command line. ``` diff --git a/.github/README.md b/.github/README.md index 195d4f9a36c7..33cb2d41d6e2 100644 --- a/.github/README.md +++ b/.github/README.md @@ -38,6 +38,14 @@ Some important documentation links to get you started: - [Getting to know Umbraco](https://docs.umbraco.com/umbraco-cms/fundamentals/get-to-know-umbraco) - [Tutorials for creating a basic website and customizing the editing experience](https://docs.umbraco.com/umbraco-cms/tutorials/overview) +## Backoffice Preview + +Want to see the latest backoffice UI in action? Check out our live preview: + +**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)** + +This preview is automatically deployed from the main branch and showcases the latest backoffice features and improvements. It runs from mock data and persistent edits are not supported. + ## Get help If you need a bit of feedback while building your Umbraco projects, we are [chatty on Discord](https://discord.umbraco.com). Our Discord server serves as a social space for all Umbracians. If you have any questions or need some help with a problem, head over to our [dedicated forum](https://forum.umbraco.com/) where the Umbraco Community will be happy to help. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b438a0027b48..858984a9d38a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,19 +94,34 @@ The solution contains 30 C# projects organized as follows: ## Common Tasks -### Frontend Development -For frontend-only changes: -1. Configure backend for frontend development: - ```json - +### Running Umbraco in Different Modes + +**Production Mode (Standard Development)** +Use this for backend development, testing full builds, or when you don't need hot reloading: +1. Build frontend assets: `cd src/Umbraco.Web.UI.Client && npm run build:for:cms` +2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build` +3. Access backoffice: `https://localhost:44339/umbraco` +4. Application uses compiled frontend from `wwwroot/umbraco/backoffice/` + +**Vite Dev Server Mode (Frontend Development with Hot Reload)** +Use this for frontend-only development with hot module reloading: +1. Configure backend for frontend development - Add to `src/Umbraco.Web.UI/appsettings.json` under `Umbraco:CMS:Security`: ```json "BackOfficeHost": "http://localhost:5173", "AuthorizeCallbackPathName": "/oauth_complete", "AuthorizeCallbackLogoutPathName": "/logout", - "AuthorizeCallbackErrorPathName": "/error" + "AuthorizeCallbackErrorPathName": "/error", + "BackOfficeTokenCookie": { + "Enabled": true, + "SameSite": "None" + } ``` 2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build` 3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server` +4. Access backoffice: `http://localhost:5173/` (no `/umbraco` prefix) +5. Changes to TypeScript/Lit files hot reload automatically + +**Important:** Remove the `BackOfficeHost` configuration before committing or switching back to production mode. ### Backend-Only Development For backend-only changes, disable frontend builds: diff --git a/.github/workflows/label-to-release-announcement.yml b/.github/workflows/label-to-release-announcement.yml index 013b36d60c50..b8cd447b6e61 100644 --- a/.github/workflows/label-to-release-announcement.yml +++ b/.github/workflows/label-to-release-announcement.yml @@ -52,7 +52,7 @@ jobs: for (const item of items) { const releaseLabels = (item.labels || []) .map(l => (typeof l === "string" ? l : l.name)) // always get the name - .filter(n => typeof n === "string" && n.startsWith("release/")); + .filter(n => typeof n === "string" && n.startsWith("release/") && n !== "release/no-notes"); if (releaseLabels.length === 0) continue; core.info(`#${item.number}: ${releaseLabels.join(", ")}`); diff --git a/.vscode/launch.json b/.vscode/launch.json index ef4677989e20..c56f06dc2f8d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -101,10 +101,14 @@ "env": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_URLS": "https://localhost:44339", + "UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL": "https://localhost:44339", "UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173", "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete", "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout", - "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error" + "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error", + "UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true", + "UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__ENABLED": "true", + "UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None" }, "sourceFileMap": { "/Views": "${workspaceFolder}/Umbraco.Web.UI/Views" diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 1de1217a33c4..ba4e3d13e464 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -9,8 +9,8 @@ schedules: branches: include: - v15/dev + - v16/dev - main - - v17/dev parameters: - name: skipIntegrationTests @@ -18,10 +18,10 @@ parameters: type: boolean default: false - - name: differentAppSettingsAcceptanceTests - displayName: Run acceptance tests with different app settings + - name: skipDifferentAppSettingsAcceptanceTests + displayName: Skip acceptance tests with different app settings type: boolean - default: true + default: false - name: skipDefaultConfigAcceptanceTests displayName: Skip tests with DefaultConfig @@ -443,15 +443,15 @@ stages: vmImage: "ubuntu-latest" CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" WindowsPart1Of3: - testCommand: "npm run test -- --shard=1/3" + testCommand: "npm run testWindows -- --shard=1/3" testFolder: "DefaultConfig" vmImage: "windows-latest" WindowsPart2Of3: - testCommand: "npm run test -- --shard=2/3" + testCommand: "npm run testWindows -- --shard=2/3" testFolder: "DefaultConfig" vmImage: "windows-latest" WindowsPart3Of3: - testCommand: "npm run test -- --shard=3/3" + testCommand: "npm run testWindows -- --shard=3/3" testFolder: "DefaultConfig" vmImage: "windows-latest" pool: @@ -505,7 +505,7 @@ stages: jobs: - job: displayName: E2E Tests with Different App settings (SQL Server) - condition: ${{ eq(parameters.differentAppSettingsAcceptanceTests, true) }} + condition: ${{ eq(parameters.skipDifferentAppSettingsAcceptanceTests, false) }} timeoutInMinutes: 180 variables: SA_PASSWORD: UmbracoAcceptance123! @@ -564,6 +564,23 @@ stages: CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient additionalEnvironmentVariables: false + # EntityDataPicker + WindowsEntityDataPicker: + vmImage: "windows-latest" + testFolder: "EntityDataPicker" + port: '' + testCommand: "npx playwright test --project=entityDataPicker" + CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True + CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + additionalEnvironmentVariables: false + LinuxEntityDataPicker: + vmImage: "ubuntu-latest" + testFolder: "EntityDataPicker" + port: '' + testCommand: "npx playwright test --project=entityDataPicker" + CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True + CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + additionalEnvironmentVariables: false pool: vmImage: $(vmImage) steps: @@ -695,4 +712,4 @@ stages: --data "$PAYLOAD" \ "$SLACK_WEBHOOK_URL" env: - SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL) \ No newline at end of file + SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL) diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs new file mode 100644 index 000000000000..8d1dbd040ed4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs @@ -0,0 +1,187 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using OpenIddict.Server; +using OpenIddict.Validation; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Common.DependencyInjection; + +internal sealed class HideBackOfficeTokensHandler + : IOpenIddictServerHandler, + IOpenIddictServerHandler, + IOpenIddictValidationHandler, + INotificationHandler +{ + private const string RedactedTokenValue = "[redacted]"; + private const string AccessTokenCookieKey = "__Host-umbAccessToken"; + private const string RefreshTokenCookieKey = "__Host-umbRefreshToken"; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings; + private readonly GlobalSettings _globalSettings; + + public HideBackOfficeTokensHandler( + IHttpContextAccessor httpContextAccessor, + IDataProtectionProvider dataProtectionProvider, + IOptions backOfficeTokenCookieSettings, + IOptions globalSettings) + { + _httpContextAccessor = httpContextAccessor; + _dataProtectionProvider = dataProtectionProvider; + _backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value; + _globalSettings = globalSettings.Value; + } + + /// + /// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client, + /// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the + /// tokens from the response, so they are not exposed to the client. + /// + public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context) + { + if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice) + { + // Only ever handle the back-office client. + return ValueTask.CompletedTask; + } + + HttpContext httpContext = GetHttpContext(); + + if (context.Response.AccessToken is not null) + { + SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken); + context.Response.AccessToken = RedactedTokenValue; + } + + if (context.Response.RefreshToken is not null) + { + SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken); + context.Response.RefreshToken = RedactedTokenValue; + } + + return ValueTask.CompletedTask; + } + + /// + /// This is invoked when requesting new tokens. + /// + public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context) + { + if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice) + { + // Only ever handle the back-office client. + return ValueTask.CompletedTask; + } + + // For the back-office client, this only happens when a refresh token is being exchanged for a new access token. + if (context.Request.RefreshToken == RedactedTokenValue + && TryGetCookie(RefreshTokenCookieKey, out var refreshToken)) + { + context.Request.RefreshToken = refreshToken; + } + else + { + // If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie. + // If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security + // reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted. + context.Request.RefreshToken = null; + } + + + return ValueTask.CompletedTask; + } + + /// + /// This is invoked when extracting the auth context for a client request. + /// + public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context) + { + // For the back-office client, this only happens when an access token is sent to the API. + if (context.AccessToken != RedactedTokenValue) + { + return ValueTask.CompletedTask; + } + + if (TryGetCookie(AccessTokenCookieKey, out var accessToken)) + { + context.AccessToken = accessToken; + } + + return ValueTask.CompletedTask; + } + + public void Handle(UserLogoutSuccessNotification notification) + { + HttpContext? context = _httpContextAccessor.HttpContext; + if (context is null) + { + // For some reason there is no ambient HTTP context, so we can't clean up the cookies. + // This is OK, because the tokens in the cookies have already been revoked at user sign-out, + // so the cookie clean-up is mostly cosmetic. + return; + } + + context.Response.Cookies.Delete(AccessTokenCookieKey); + context.Response.Cookies.Delete(RefreshTokenCookieKey); + } + + private HttpContext GetHttpContext() + => _httpContextAccessor.GetRequiredHttpContext(); + + private void SetCookie(HttpContext httpContext, string key, string value) + { + var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider); + + var cookieOptions = new CookieOptions + { + // Prevent the client-side scripts from accessing the cookie. + HttpOnly = true, + + // Mark the cookie as essential to the application, to enforce it despite any + // data collection consent options. This aligns with how ASP.NET Core Identity + // does when writing cookies for cookie authentication. + IsEssential = true, + + // Cookie path must be root for optimal security. + Path = "/", + + // For optimal security, the cooke must be secure. However, Umbraco allows for running development + // environments over HTTP, so we need to take that into account here. + // Thus, we will make the cookie secure if: + // - HTTPS is explicitly enabled by config (default for production environments), or + // - The current request is over HTTPS (meaning the environment supports it regardless of config). + Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps, + + // SameSite is configurable (see BackOfficeTokenCookieSettings for defaults): + SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite), + }; + + httpContext.Response.Cookies.Delete(key, cookieOptions); + httpContext.Response.Cookies.Append(key, cookieValue, cookieOptions); + } + + private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value) + { + if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue)) + { + value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider); + return true; + } + + value = null; + return false; + } + + private static SameSiteMode ParseSameSiteMode(string sameSiteMode) => + Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result) + ? result + : throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode)); +} diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 82cc61dc185b..487de19908d8 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Infrastructure.BackgroundJobs; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; +using Umbraco.Cms.Core.Notifications; namespace Umbraco.Cms.Api.Common.DependencyInjection; @@ -28,6 +29,11 @@ public static IUmbracoBuilder AddUmbracoOpenIddict(this IUmbracoBuilder builder) private static void ConfigureOpenIddict(IUmbracoBuilder builder) { + // Optionally hide tokens from the back-office. + var hideBackOfficeTokens = (builder.Config + .GetSection(Constants.Configuration.ConfigBackOfficeTokenCookie) + .Get() ?? new BackOfficeTokenCookieSettings()).Enabled; + builder.Services.AddOpenIddict() // Register the OpenIddict server components. .AddServer(options => @@ -113,6 +119,22 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder) { configuration.UseSingletonHandler().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1); }); + + if (hideBackOfficeTokens) + { + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse.Descriptor.Order - 1); + }); + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest.Descriptor.Order + 1); + }); + } }) // Register the OpenIddict validation components. @@ -137,9 +159,25 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder) { configuration.UseSingletonHandler().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1); }); + + if (hideBackOfficeTokens) + { + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + // IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string. + .SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1); + }); + } }); builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); + + if (hideBackOfficeTokens) + { + builder.AddNotificationHandler(); + } } } diff --git a/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs b/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs new file mode 100644 index 000000000000..4f3444ae31a5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using OpenIddict.Server; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Security; + +/// +/// Provides OpenIddict server event handlers to expose the backoffice authentication token via a custom authentication scheme. +/// +public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpenIddictServerHandler, + IOpenIddictServerHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly string[] _claimTypes; + private readonly TimeSpan _timeOut; + + /// + /// Initializes a new instance of the class. + /// + public ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler( + IHttpContextAccessor httpContextAccessor, + IOptions globalSettings, + IOptions backOfficeIdentityOptions) + { + _httpContextAccessor = httpContextAccessor; + _timeOut = globalSettings.Value.TimeOut; + + // These are the type identifiers for the claims required by the principal + // for the custom authentication scheme. + // We make available the ID, user name and allowed applications (sections) claims. + _claimTypes = + [ + backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType, + backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType, + Core.Constants.Security.AllowedApplicationsClaimType, + ]; + } + + /// + /// + /// Event handler for when access tokens are generated (created or refreshed). + /// + public async ValueTask HandleAsync(OpenIddictServerEvents.GenerateTokenContext context) + { + // Only proceed if this is a back-office sign-in. + if (context.Principal.Identity?.AuthenticationType != Core.Constants.Security.BackOfficeAuthenticationType) + { + return; + } + + // Create a new principal with the claims from the authenticated principal. + var principal = new ClaimsPrincipal( + new ClaimsIdentity( + context.Principal.Claims.Where(claim => _claimTypes.Contains(claim.Type)), + Core.Constants.Security.BackOfficeExposedAuthenticationType)); + + // Sign-in the new principal for the custom authentication scheme. + await _httpContextAccessor + .GetRequiredHttpContext() + .SignInAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, principal, GetAuthenticationProperties()); + } + + /// + /// + /// Event handler for when access tokens are revoked. + /// + public async ValueTask HandleAsync(OpenIddictServerEvents.ApplyRevocationResponseContext context) + => await _httpContextAccessor + .GetRequiredHttpContext() + .SignOutAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, GetAuthenticationProperties()); + + private AuthenticationProperties GetAuthenticationProperties() + => new() + { + IsPersistent = true, + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.Add(_timeOut) + }; +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs index b633f7bdd6ef..3811587e4bfd 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Server; using Umbraco.Cms.Api.Common.DependencyInjection; using Umbraco.Cms.Api.Management.Configuration; using Umbraco.Cms.Api.Management.Handlers; @@ -50,6 +52,7 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) { builder.Services .AddAuthentication() + // Add our custom schemes which are cookie handlers .AddCookie(Constants.Security.BackOfficeAuthenticationType) .AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o => @@ -58,6 +61,15 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) o.ExpireTimeSpan = TimeSpan.FromMinutes(5); }) + // Add a cookie scheme that can be used for authenticating backoffice users outside the scope of the backoffice. + .AddCookie(Constants.Security.BackOfficeExposedAuthenticationType, options => + { + options.Cookie.Name = Constants.Security.BackOfficeExposedCookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.SlidingExpiration = true; + }) + // Although we don't natively support this, we add it anyways so that if end-users implement the required logic // they don't have to worry about manually adding this scheme or modifying the sign in manager .AddCookie(Constants.Security.BackOfficeTwoFactorAuthenticationType, options => @@ -71,6 +83,22 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) o.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); + // Add OpnIddict server event handler to refresh the cookie that exposes the backoffice authentication outside the scope of the backoffice. + builder.Services.AddSingleton(); + builder.Services.Configure(options => + { + options.Handlers.Add( + OpenIddictServerHandlerDescriptor + .CreateBuilder() + .UseSingletonHandler() + .Build()); + options.Handlers.Add( + OpenIddictServerHandlerDescriptor + .CreateBuilder() + .UseSingletonHandler() + .Build()); + }); + builder.Services.AddScoped(); builder.Services.ConfigureOptions(); builder.Services.ConfigureOptions(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs index 5d3f579c58b9..6ec71e8a5c11 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs @@ -1,13 +1,16 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Mapping.Content; using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; using Umbraco.Cms.Api.Management.ViewModels.MediaType; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Mapping; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Mapping.Media; @@ -15,13 +18,32 @@ namespace Umbraco.Cms.Api.Management.Mapping.Media; public class MediaMapDefinition : ContentMapDefinition, IMapDefinition { private readonly CommonMapper _commonMapper; + private ContentSettings _contentSettings; public MediaMapDefinition( PropertyEditorCollection propertyEditorCollection, CommonMapper commonMapper, - IDataValueEditorFactory dataValueEditorFactory) + IDataValueEditorFactory dataValueEditorFactory, + IOptionsMonitor contentSettings) : base(propertyEditorCollection, dataValueEditorFactory) - => _commonMapper = commonMapper; + { + _commonMapper = commonMapper; + _contentSettings = contentSettings.CurrentValue; + contentSettings.OnChange(x => _contentSettings = x); + } + + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")] + public MediaMapDefinition( + PropertyEditorCollection propertyEditorCollection, + CommonMapper commonMapper, + IDataValueEditorFactory dataValueEditorFactory) + : this( + propertyEditorCollection, + commonMapper, + dataValueEditorFactory, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")] public MediaMapDefinition( @@ -48,6 +70,39 @@ private void Map(IMedia source, MediaResponseModel target, MapperContext context target.Values = MapValueViewModels(source.Properties); target.Variants = MapVariantViewModels(source); target.IsTrashed = source.Trashed; + + // If protection for media files in the recycle bin is enabled, and the media item is trashed, amend the value of the file path + // to have the `.deleted` suffix that will have been added to the persisted file. + if (target.IsTrashed && _contentSettings.EnableMediaRecycleBinProtection) + { + foreach (MediaValueResponseModel valueModel in target.Values + .Where(x => x.EditorAlias.Equals(Core.Constants.PropertyEditors.Aliases.ImageCropper))) + { + if (valueModel.Value is not null && + valueModel.Value is ImageCropperValue imageCropperValue && + string.IsNullOrWhiteSpace(imageCropperValue.Src) is false) + { + valueModel.Value = new ImageCropperValue + { + Crops = imageCropperValue.Crops, + FocalPoint = imageCropperValue.FocalPoint, + TemporaryFileId = imageCropperValue.TemporaryFileId, + Src = SuffixMediaPath(imageCropperValue.Src, Core.Constants.Conventions.Media.TrashedMediaSuffix), + }; + } + } + } + } + + private static string SuffixMediaPath(string filePath, string suffix) + { + int lastDotIndex = filePath.LastIndexOf('.'); + if (lastDotIndex == -1) + { + return filePath + suffix; + } + + return filePath[..lastDotIndex] + suffix + filePath[lastDotIndex..]; } // Umbraco.Code.MapAll -Flags diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index f64726a32354..8600277831a6 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -40574,7 +40574,7 @@ }, "actionParameters": { "type": "object", - "additionalProperties": { }, + "additionalProperties": {}, "nullable": true } }, @@ -41199,7 +41199,7 @@ }, "extensions": { "type": "array", - "items": { } + "items": {} } }, "additionalProperties": false @@ -45071,7 +45071,7 @@ "nullable": true } }, - "additionalProperties": { } + "additionalProperties": {} }, "ProblemDetailsBuilderModel": { "type": "object", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Installer/UserInstallRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Installer/UserInstallRequestModel.cs index b14a67e72654..2dd608178e61 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Installer/UserInstallRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Installer/UserInstallRequestModel.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Umbraco.Cms.Api.Management.ViewModels.Installer; @@ -17,5 +17,5 @@ public class UserInstallRequestModel [PasswordPropertyText] public string Password { get; set; } = string.Empty; - public bool SubscribeToNewsletter { get; } + public bool SubscribeToNewsletter { get; set; } } diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDateAndTimeOnlyMapper.cs b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDateAndTimeOnlyMapper.cs new file mode 100644 index 000000000000..4e3ec6a411f1 --- /dev/null +++ b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDateAndTimeOnlyMapper.cs @@ -0,0 +1,64 @@ +using NPoco; + +namespace Umbraco.Cms.Persistence.Sqlite.Mappers; + +/// +/// Provides a custom POCO mapper for handling date and time only values when working with SQLite databases. +/// +public class SqlitePocoDateAndTimeOnlyMapper : DefaultMapper +{ + /// + public override Func GetFromDbConverter(Type destType, Type sourceType) + { + if (IsDateOnlyType(destType)) + { + return value => ConvertToDateOnly(value, IsNullableType(destType)); + } + + if (IsTimeOnlyType(destType)) + { + return value => ConvertToTimeOnly(value, IsNullableType(destType)); + } + + return base.GetFromDbConverter(destType, sourceType); + } + + private static bool IsDateOnlyType(Type type) => + type == typeof(DateOnly) || type == typeof(DateOnly?); + + private static bool IsTimeOnlyType(Type type) => + type == typeof(TimeOnly) || type == typeof(TimeOnly?); + + private static bool IsNullableType(Type type) => + Nullable.GetUnderlyingType(type) != null; + + private static object? ConvertToDateOnly(object? value, bool isNullable) + { + if (value is null) + { + return isNullable ? null : default(DateOnly); + } + + if (value is DateTime dt) + { + return DateOnly.FromDateTime(dt); + } + + return DateOnly.Parse(value.ToString()!); + } + + private static object? ConvertToTimeOnly(object? value, bool isNullable) + { + if (value is null) + { + return isNullable ? null : default(TimeOnly); + } + + if (value is DateTime dt) + { + return TimeOnly.FromDateTime(dt); + } + + return TimeOnly.Parse(value.ToString()!); + } +} diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDecimalMapper.cs b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDecimalMapper.cs new file mode 100644 index 000000000000..567b1cbcb8f9 --- /dev/null +++ b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoDecimalMapper.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using NPoco; + +namespace Umbraco.Cms.Persistence.Sqlite.Mappers; + +/// +/// Provides a custom POCO mapper for handling decimal values when working with SQLite databases. +/// +public class SqlitePocoDecimalMapper : DefaultMapper +{ + /// + public override Func GetFromDbConverter(Type destType, Type sourceType) + { + if (destType == typeof(decimal)) + { + return value => Convert.ToDecimal(value, CultureInfo.InvariantCulture); + } + + if (destType == typeof(decimal?)) + { + return value => Convert.ToDecimal(value, CultureInfo.InvariantCulture); + } + + return base.GetFromDbConverter(destType, sourceType); + } +} diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoGuidMapper.cs b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoGuidMapper.cs index ab62b4b1d1c6..ffb96a6b2b0e 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoGuidMapper.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Mappers/SqlitePocoGuidMapper.cs @@ -1,19 +1,18 @@ -using System.Globalization; using NPoco; namespace Umbraco.Cms.Persistence.Sqlite.Mappers; +/// +/// Provides a custom POCO mapper for handling GUID values when working with SQLite databases. +/// public class SqlitePocoGuidMapper : DefaultMapper { + /// public override Func GetFromDbConverter(Type destType, Type sourceType) { if (destType == typeof(Guid)) { - return value => - { - var result = Guid.Parse($"{value}"); - return result; - }; + return value => Guid.Parse($"{value}"); } if (destType == typeof(Guid?)) @@ -29,24 +28,6 @@ public class SqlitePocoGuidMapper : DefaultMapper }; } - if (destType == typeof(decimal)) - { - return value => - { - var result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); - return result; - }; - } - - if (destType == typeof(decimal?)) - { - return value => - { - var result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); - return result; - }; - } - return base.GetFromDbConverter(destType, sourceType); } } diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSpecificMapperFactory.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSpecificMapperFactory.cs index 66f542712a3c..9c577d6329dd 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSpecificMapperFactory.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSpecificMapperFactory.cs @@ -12,5 +12,5 @@ public class SqliteSpecificMapperFactory : IProviderSpecificMapperFactory public string ProviderName => Constants.ProviderName; /// - public NPocoMapperCollection Mappers => new(() => new[] { new SqlitePocoGuidMapper() }); + public NPocoMapperCollection Mappers => new(() => [new SqlitePocoGuidMapper(), new SqlitePocoDecimalMapper(), new SqlitePocoDateAndTimeOnlyMapper()]); } diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 7505781ad95a..b1307ef1e8bb 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; @@ -151,6 +149,12 @@ public override void Refresh(JsonPayload[] payloads) } + // Clear partial view cache when published content changes + if (ShouldClearPartialViewCache(payloads)) + { + AppCaches.ClearPartialViewCache(); + } + if (idsRemoved.Count > 0) { var assignedDomains = _domainService.GetAll(true) @@ -175,6 +179,28 @@ public override void Refresh(JsonPayload[] payloads) base.Refresh(payloads); } + private static bool ShouldClearPartialViewCache(JsonPayload[] payloads) + { + return payloads.Any(x => + { + // Check for relelvant change type + var isRelevantChangeType = x.ChangeTypes.HasType(TreeChangeTypes.RefreshAll) || + x.ChangeTypes.HasType(TreeChangeTypes.Remove) || + x.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || + x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch); + + // Check for published/unpublished changes + var hasChanges = x.PublishedCultures?.Length > 0 || + x.UnpublishedCultures?.Length > 0; + + // There's no other way to detect trashed content as the change type is only Remove when deleted permanently + var isTrashed = x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch) && x.PublishedCultures is null && x.UnpublishedCultures is null; + + // Skip blueprints and only clear the partial cache for removals or refreshes with changes + return x.Blueprint == false && (isTrashed || (isRelevantChangeType && hasChanges)); + }); + } + private void HandleMemoryCache(JsonPayload payload) { Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; @@ -365,7 +391,7 @@ private async Task HandlePublishedAsync(JsonPayload payload, CancellationToken c } private void HandleRouting(JsonPayload payload) { - if(payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; @@ -374,24 +400,24 @@ private void HandleRouting(JsonPayload payload) { _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeys).GetAwaiter().GetResult(); } - else if(_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out var descendantsOrSelfKeysInBin)) + else if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out var descendantsOrSelfKeysInBin)) { _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeysInBin).GetAwaiter().GetResult(); } } - if(payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) { _documentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); //TODO make async } - if(payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode)) + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode)) { var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; _documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult(); } - if(payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) { var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult(); diff --git a/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs b/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs new file mode 100644 index 000000000000..4019c425474f --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for back-office token cookie settings. +/// +[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)] +[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")] +public class BackOfficeTokenCookieSettings +{ + private const bool StaticEnabled = false; + + private const string StaticSameSite = "Strict"; + + /// + /// Gets or sets a value indicating whether to enable access and refresh tokens in cookies. + /// + [DefaultValue(StaticEnabled)] + [Obsolete("This is only configurable in Umbraco 16. Scheduled for removal in Umbraco 17.")] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets a value indicating whether the cookie SameSite configuration. + /// + /// + /// Valid values are "Unspecified", "None", "Lax" and "Strict" (default). + /// + [DefaultValue(StaticSameSite)] + public string SameSite { get; set; } = StaticSameSite; +} diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index 6df9b429e907..645d21c56711 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -31,6 +31,9 @@ public class ContentSettings internal const bool StaticShowDomainWarnings = true; internal const bool StaticShowUnroutableContentWarnings = true; + // TODO (V18): Consider enabling this by default and documenting as a behavioural breaking change. + private const bool StaticEnableMediaRecycleBinProtection = false; + /// /// Gets or sets a value for the content notification settings. /// @@ -158,4 +161,16 @@ public class ContentSettings /// [DefaultValue(StaticShowUnroutableContentWarnings)] public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings; + + /// + /// Gets or sets a value indicating whether to enable or disable the recycle bin protection for media. + /// + /// + /// When set to true, this will: + /// - Rename media moved to the recycle bin to have a .deleted suffice (e.g. image.jpg will be renamed to image.deleted.jpg). + /// - On restore, the media file will be renamed back to its original name. + /// - A middleware component will be enabled to prevent access to media files in the recycle bin unless the user is authenticated with access to the media section. + /// + [DefaultValue(StaticEnableMediaRecycleBinProtection)] + public bool EnableMediaRecycleBinProtection { get; set; } = StaticEnableMediaRecycleBinProtection; } diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index 40f01954a421..1b71e5677cef 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -181,6 +181,11 @@ internal const string /// public bool IsSmtpServerConfigured => !string.IsNullOrWhiteSpace(Smtp?.Host); + /// + /// Gets a value indicating whether SMTP expiry is configured. + /// + public bool IsSmtpExpiryConfigured => Smtp?.EmailExpiration != null && Smtp?.EmailExpiration.HasValue == true; + /// /// Gets a value indicating whether there is a physical pickup directory configured. /// diff --git a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs index 32bfeedb51ed..9227c3158501 100644 --- a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.ComponentModel; + namespace Umbraco.Cms.Core.Configuration.Models; /// diff --git a/src/Umbraco.Core/Configuration/Models/IndexingSettings.cs b/src/Umbraco.Core/Configuration/Models/IndexingSettings.cs index ff3bebd9894a..282d6c57fb8e 100644 --- a/src/Umbraco.Core/Configuration/Models/IndexingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/IndexingSettings.cs @@ -9,10 +9,16 @@ namespace Umbraco.Cms.Core.Configuration.Models; public class IndexingSettings { private const bool StaticExplicitlyIndexEachNestedProperty = false; + private const int StaticBatchSize = 10000; /// /// Gets or sets a value for whether each nested property should have it's own indexed value. Requires a rebuild of indexes when changed. /// [DefaultValue(StaticExplicitlyIndexEachNestedProperty)] public bool ExplicitlyIndexEachNestedProperty { get; set; } = StaticExplicitlyIndexEachNestedProperty; + + /// + /// Gets or sets a value for how many items to index at a time. + /// + public int BatchSize { get; set; } = StaticBatchSize; } diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index bfff570c4f7c..0602ab6e8ea3 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -34,6 +34,9 @@ public class SecuritySettings internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout"; internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error"; + internal const string StaticPasswordResetEmailExpiry = "01:00:00"; + internal const string StaticUserInviteEmailExpiry = "3.00:00:00"; + /// /// Gets or sets a value indicating whether to keep the user logged in. /// @@ -159,4 +162,16 @@ public class SecuritySettings /// [DefaultValue(StaticAuthorizeCallbackErrorPathName)] public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName; + + /// + /// Gets or sets the expiry time for password reset emails. + /// + [DefaultValue(StaticPasswordResetEmailExpiry)] + public TimeSpan PasswordResetEmailExpiry { get; set; } = TimeSpan.Parse(StaticPasswordResetEmailExpiry); + + /// + /// Gets or sets the expiry time for user invite emails. + /// + [DefaultValue(StaticUserInviteEmailExpiry)] + public TimeSpan UserInviteEmailExpiry { get; set; } = TimeSpan.Parse(StaticUserInviteEmailExpiry); } diff --git a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs index 92229b1b6d08..ea56445aa28e 100644 --- a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs @@ -96,4 +96,9 @@ public class SmtpSettings : ValidatableEntryBase /// Gets or sets a value for the SMTP password. /// public string? Password { get; set; } + + /// + /// Gets or sets a value for the time until an email expires. + /// + public TimeSpan? EmailExpiration { get; set; } } diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 8ac24c71f45d..02236f2cf98c 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -67,6 +67,7 @@ public static class Configuration public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType"; public const string ConfigCache = ConfigPrefix + "Cache"; public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs"; + public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie"; public static class NamedOptions { diff --git a/src/Umbraco.Core/Constants-Conventions.cs b/src/Umbraco.Core/Constants-Conventions.cs index 2d21c544e50f..e117b42189ae 100644 --- a/src/Umbraco.Core/Constants-Conventions.cs +++ b/src/Umbraco.Core/Constants-Conventions.cs @@ -100,6 +100,11 @@ public static class Media /// The default height/width of an image file if the size can't be determined from the metadata /// public const int DefaultSize = 200; + + /// + /// Suffix added to media files when moved to the recycle bin when recycle bin media protection is enabled. + /// + public const string TrashedMediaSuffix = ".deleted"; } /// diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index eb77642f1bb9..6567fc99b33e 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -73,6 +73,17 @@ public static class Security public const string BackOfficeTokenAuthenticationType = "UmbracoBackOfficeToken"; public const string BackOfficeTwoFactorAuthenticationType = "UmbracoTwoFactorCookie"; public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie"; + + /// + /// Authentication type and scheme used for backoffice users when it is exposed out of the backoffice context via a cookie. + /// + public const string BackOfficeExposedAuthenticationType = "UmbracoBackOfficeExposed"; + + /// + /// Represents the name of the authentication cookie used to expose the backoffice authentication token outside of the backoffice context. + /// + public const string BackOfficeExposedCookieName = "UMB_UCONTEXT_EXPOSED"; + public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__"; public const string DefaultMemberTypeAlias = "Member"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 84462dda981b..e56a4cef2780 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -88,7 +88,8 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs b/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs index 022531c1eccf..6f373cb006c0 100644 --- a/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs +++ b/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs @@ -74,7 +74,7 @@ public override async Task SendAsync(HealthCheckResults results) var subject = _textService?.Localize("healthcheck", "scheduledHealthCheckEmailSubject", new[] { host }); EmailMessage mailMessage = CreateMailMessage(subject, message); - Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck); + Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck, false, null); if (task is not null) { await task; diff --git a/src/Umbraco.Core/IO/IFileSystem.cs b/src/Umbraco.Core/IO/IFileSystem.cs index da9dd0b9bba4..390556f74e59 100644 --- a/src/Umbraco.Core/IO/IFileSystem.cs +++ b/src/Umbraco.Core/IO/IFileSystem.cs @@ -168,10 +168,42 @@ public interface IFileSystem /// A value indicating whether to move (default) or copy. void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false); + /// + /// Moves a file from the specified source path to the specified target path. + /// + /// The path of the file or directory to move. + /// The destination path where the file or directory will be moved. + /// A value indicating what to do if the file already exists. + void MoveFile(string source, string target, bool overrideIfExists = true) + { + // Provide a default implementation for implementations of IFileSystem that do not implement this method. + if (FileExists(source) is false) + { + throw new FileNotFoundException($"File at path '{source}' could not be found."); + } + + if (FileExists(target)) + { + if (overrideIfExists) + { + DeleteFile(target); + } + else + { + throw new IOException($"A file at path '{target}' already exists."); + } + } + + using (Stream sourceStream = OpenFile(source)) + { + AddFile(target, sourceStream); + } + + DeleteFile(source); + } + // TODO: implement these // // void CreateDirectory(string path); // - //// move or rename, directory or file - // void Move(string source, string target); } diff --git a/src/Umbraco.Core/IO/MediaFileManager.cs b/src/Umbraco.Core/IO/MediaFileManager.cs index fe9f829567b9..6b100db532e1 100644 --- a/src/Umbraco.Core/IO/MediaFileManager.cs +++ b/src/Umbraco.Core/IO/MediaFileManager.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Strings; @@ -40,7 +38,58 @@ public MediaFileManager( /// Delete media files. /// /// Files to delete (filesystem-relative paths). - public void DeleteMediaFiles(IEnumerable files) + public void DeleteMediaFiles(IEnumerable files) => + PerformMediaFileOperation( + files, + file => + { + FileSystem.DeleteFile(file); + + var directory = _mediaPathScheme.GetDeleteDirectory(this, file); + if (!directory.IsNullOrWhiteSpace()) + { + FileSystem.DeleteDirectory(directory!, true); + } + }, + "Failed to delete media file '{File}'."); + + /// + /// Adds a suffix to media files. + /// + /// Files to append a suffix to. + /// The suffix to append. + /// + /// The suffix will be added prior to the file extension, e.g. "image.jpg" with suffix ".deleted" will become "image.deleted.jpg". + /// + public void SuffixMediaFiles(IEnumerable files, string suffix) + => PerformMediaFileOperation( + files, + file => + { + var suffixedFile = Path.ChangeExtension(file, suffix + Path.GetExtension(file)); + FileSystem.MoveFile(file, suffixedFile); + }, + "Failed to rename media file '{File}'."); + + /// + /// Removes a suffix from media files. + /// + /// Files to remove a suffix from. + /// The suffix to remove. + /// + /// The suffix will be removed prior to the file extension, e.g. "image.deleted.jpg" with suffix ".deleted" will become "image.jpg". + /// + public void RemoveSuffixFromMediaFiles(IEnumerable files, string suffix) + => PerformMediaFileOperation( + files, + file => + { + var fileWithSuffixRemoved = file.Replace(suffix + Path.GetExtension(file), Path.GetExtension(file)); + FileSystem.MoveFile(file, fileWithSuffixRemoved); + }, + "Failed to rename media file '{File}'."); + + private void PerformMediaFileOperation(IEnumerable files, Action fileOperation, string errorMessage) { files = files.Distinct(); @@ -61,17 +110,11 @@ public void DeleteMediaFiles(IEnumerable files) return; } - FileSystem.DeleteFile(file); - - var directory = _mediaPathScheme.GetDeleteDirectory(this, file); - if (!directory.IsNullOrWhiteSpace()) - { - FileSystem.DeleteDirectory(directory!, true); - } + fileOperation(file); } catch (Exception e) { - _logger.LogError(e, "Failed to delete media file '{File}'.", file); + _logger.LogError(e, errorMessage, file); } }); } diff --git a/src/Umbraco.Core/IO/PhysicalFileSystem.cs b/src/Umbraco.Core/IO/PhysicalFileSystem.cs index 32f0d0fdabcc..9c21d056db04 100644 --- a/src/Umbraco.Core/IO/PhysicalFileSystem.cs +++ b/src/Umbraco.Core/IO/PhysicalFileSystem.cs @@ -428,13 +428,9 @@ public void AddFile(string path, string physicalPath, bool overrideIfExists = tr WithRetry(() => File.Delete(fullPath)); } - var directory = Path.GetDirectoryName(fullPath); - if (directory == null) - { - throw new InvalidOperationException("Could not get directory."); - } - - Directory.CreateDirectory(directory); // ensure it exists + // Ensure the directory exists. + var directory = Path.GetDirectoryName(fullPath) ?? throw new InvalidOperationException("Could not get directory."); + Directory.CreateDirectory(directory); if (copy) { @@ -446,6 +442,35 @@ public void AddFile(string path, string physicalPath, bool overrideIfExists = tr } } + /// + public void MoveFile(string source, string target, bool overrideIfExists = true) + { + var fullSourcePath = GetFullPath(source); + if (File.Exists(fullSourcePath) is false) + { + throw new FileNotFoundException($"File at path '{source}' could not be found."); + } + + var fullTargetPath = GetFullPath(target); + if (File.Exists(fullTargetPath)) + { + if (overrideIfExists) + { + DeleteFile(target); + } + else + { + throw new IOException($"A file at path '{target}' already exists."); + } + } + + // Ensure the directory exists. + var directory = Path.GetDirectoryName(fullTargetPath) ?? throw new InvalidOperationException("Could not get directory."); + Directory.CreateDirectory(directory); + + WithRetry(() => File.Move(fullSourcePath, fullTargetPath)); + } + #region Helper Methods protected virtual void EnsureDirectory(string path) diff --git a/src/Umbraco.Core/IO/ShadowFileSystem.cs b/src/Umbraco.Core/IO/ShadowFileSystem.cs index 31e884454f7b..db39fe74f4f4 100644 --- a/src/Umbraco.Core/IO/ShadowFileSystem.cs +++ b/src/Umbraco.Core/IO/ShadowFileSystem.cs @@ -91,7 +91,7 @@ public void AddFile(string path, Stream stream, bool overrideIfExists) var normPath = NormPath(path); if (Nodes.TryGetValue(normPath, out ShadowNode? sf) && sf.IsExist && (sf.IsDir || overrideIfExists == false)) { - throw new InvalidOperationException(string.Format("A file at path '{0}' already exists", path)); + throw new InvalidOperationException($"A file at path '{path}' already exists"); } var parts = normPath.Split(Constants.CharArrays.ForwardSlash); @@ -167,6 +167,60 @@ public void DeleteFile(string path) Nodes[NormPath(path)] = new ShadowNode(true, false); } + public void MoveFile(string source, string target, bool overrideIfExists = true) + { + var normSource = NormPath(source); + var normTarget = NormPath(target); + if (Nodes.TryGetValue(normSource, out ShadowNode? sf) == false || sf.IsDir || sf.IsDelete) + { + if (Inner.FileExists(source) == false) + { + throw new FileNotFoundException("Source file does not exist."); + } + } + + if (Nodes.TryGetValue(normTarget, out ShadowNode? tf) && tf.IsExist && (tf.IsDir || overrideIfExists == false)) + { + throw new IOException($"A file at path '{target}' already exists"); + } + + var parts = normTarget.Split(Constants.CharArrays.ForwardSlash); + for (var i = 0; i < parts.Length - 1; i++) + { + var dirPath = string.Join("/", parts.Take(i + 1)); + if (Nodes.TryGetValue(dirPath, out ShadowNode? sd)) + { + if (sd.IsFile) + { + throw new InvalidOperationException("Invalid path."); + } + + if (sd.IsDelete) + { + Nodes[dirPath] = new ShadowNode(false, true); + } + } + else + { + if (Inner.DirectoryExists(dirPath)) + { + continue; + } + + if (Inner.FileExists(dirPath)) + { + throw new InvalidOperationException("Invalid path."); + } + + Nodes[dirPath] = new ShadowNode(false, true); + } + } + + _sfs.MoveFile(normSource, normTarget, overrideIfExists); + Nodes[normSource] = new ShadowNode(true, false); + Nodes[normTarget] = new ShadowNode(false, false); + } + public bool FileExists(string path) { if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf)) @@ -241,7 +295,7 @@ public void AddFile(string path, string physicalPath, bool overrideIfExists = tr var normPath = NormPath(path); if (Nodes.TryGetValue(normPath, out ShadowNode? sf) && sf.IsExist && (sf.IsDir || overrideIfExists == false)) { - throw new InvalidOperationException(string.Format("A file at path '{0}' already exists", path)); + throw new InvalidOperationException($"A file at path '{path}' already exists"); } var parts = normPath.Split(Constants.CharArrays.ForwardSlash); diff --git a/src/Umbraco.Core/IO/ShadowWrapper.cs b/src/Umbraco.Core/IO/ShadowWrapper.cs index aa3d7c9b971e..9f4abc916085 100644 --- a/src/Umbraco.Core/IO/ShadowWrapper.cs +++ b/src/Umbraco.Core/IO/ShadowWrapper.cs @@ -81,6 +81,8 @@ private IFileSystem FileSystem public void DeleteFile(string path) => FileSystem.DeleteFile(path); + public void MoveFile(string source, string target) => FileSystem.MoveFile(source, target); + public bool FileExists(string path) => FileSystem.FileExists(path); public string GetRelativePath(string fullPathOrUrl) => FileSystem.GetRelativePath(fullPathOrUrl); diff --git a/src/Umbraco.Core/Mail/IEmailSender.cs b/src/Umbraco.Core/Mail/IEmailSender.cs index 2eb8cc826358..44cd9bd862b6 100644 --- a/src/Umbraco.Core/Mail/IEmailSender.cs +++ b/src/Umbraco.Core/Mail/IEmailSender.cs @@ -7,9 +7,28 @@ namespace Umbraco.Cms.Core.Mail; /// public interface IEmailSender { + /// + /// Sends a message asynchronously. + /// + [Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")] Task SendAsync(EmailMessage message, string emailType); + /// + /// Sends a message asynchronously. + /// + [Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")] Task SendAsync(EmailMessage message, string emailType, bool enableNotification); + /// + /// Sends a message asynchronously. + /// + Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) +#pragma warning disable CS0618 // Type or member is obsolete + => SendAsync(message, emailType, enableNotification); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Verifies if the email sender is configured to send emails. + /// bool CanSendRequiredEmail(); } diff --git a/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs b/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs index 7d0d2b486519..21d49db76bf9 100644 --- a/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs +++ b/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs @@ -12,6 +12,10 @@ public Task SendAsync(EmailMessage message, string emailType, bool enableNotific throw new NotImplementedException( "To send an Email ensure IEmailSender is implemented with a custom implementation"); + public Task SendAsync(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) => + throw new NotImplementedException( + "To send an Email ensure IEmailSender is implemented with a custom implementation"); + public bool CanSendRequiredEmail() => throw new NotImplementedException( "To send an Email ensure IEmailSender is implemented with a custom implementation"); diff --git a/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs index 57897587b882..c9e7a19b4424 100644 --- a/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs +++ b/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.Notifications; + /// /// A notification that is used to trigger the IMediaService when the MoveToRecycleBin method is called in the API, after the media object has been moved to the RecycleBin. /// diff --git a/src/Umbraco.Core/Persistence/Querying/IQuery.cs b/src/Umbraco.Core/Persistence/Querying/IQuery.cs index 8803d69fc048..e87ec77b86ab 100644 --- a/src/Umbraco.Core/Persistence/Querying/IQuery.cs +++ b/src/Umbraco.Core/Persistence/Querying/IQuery.cs @@ -30,6 +30,14 @@ public interface IQuery /// This instance so calls to this method are chainable IQuery WhereIn(Expression> fieldSelector, IEnumerable? values); + /// + /// Adds a where-not-in clause to the query + /// + /// + /// + /// This instance so calls to this method are chainable + IQuery WhereNotIn(Expression> fieldSelector, IEnumerable? values) => throw new NotImplementedException(); // TODO (V18): Remove default implementation. + /// /// Adds a set of OR-ed where clauses to the query. /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IPropertyTypeUsageRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IPropertyTypeUsageRepository.cs index 7aa05fc1ffc0..d0e3eb356c09 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IPropertyTypeUsageRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IPropertyTypeUsageRepository.cs @@ -1,7 +1,17 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; +/// +/// Defines repository methods for querying property type usage. +/// public interface IPropertyTypeUsageRepository { + /// + /// Determines whether there are any saved property values for the specified content type and property alias. + /// Task HasSavedPropertyValuesAsync(Guid contentTypeKey, string propertyAlias); + + /// + /// Determines whether a content type with the specified unique identifier exists. + /// Task ContentTypeExistAsync(Guid contentTypeKey); } diff --git a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs index 58c86b1426e0..9be861bff6f7 100644 --- a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs @@ -37,7 +37,6 @@ protected override IDataValueEditor CreateValueEditor() /// protected override IConfigurationEditor CreateConfigurationEditor() => new DecimalConfigurationEditor(); - /// /// Defines the value editor for the decimal property editor. /// @@ -109,7 +108,38 @@ internal abstract class DecimalPropertyConfigurationValidatorBase : SimpleProper /// protected override bool TryParsePropertyValue(object? value, out double parsedDecimalValue) - => double.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out parsedDecimalValue); + { + if (value is null) + { + parsedDecimalValue = default; + return false; + } + + if (value is decimal decimalValue) + { + parsedDecimalValue = (double)decimalValue; + return true; + } + + if (value is double doubleValue) + { + parsedDecimalValue = doubleValue; + return true; + } + + if (value is float floatValue) + { + parsedDecimalValue = (double)floatValue; + return true; + } + + if (value is IFormattable formattableValue) + { + return double.TryParse(formattableValue.ToString(null, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out parsedDecimalValue); + } + + return double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out parsedDecimalValue); + } } /// diff --git a/src/Umbraco.Core/Routing/DomainUtilities.cs b/src/Umbraco.Core/Routing/DomainUtilities.cs index 41270367219e..4b1815389859 100644 --- a/src/Umbraco.Core/Routing/DomainUtilities.cs +++ b/src/Umbraco.Core/Routing/DomainUtilities.cs @@ -1,7 +1,5 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services.Navigation; diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 9b4f92615b12..783421a2c280 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -62,6 +62,9 @@ public ContentEditingService( _languageService = languageService; } + /// + protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias; + public Task GetAsync(Guid key) { IContent? content = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 15a4b9670e3b..41e1adf7b3f1 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -75,6 +75,11 @@ protected ContentEditingServiceBase( protected TContentTypeService ContentTypeService { get; } + /// + /// Gets the alias used to relate the parent entity when handling content (document or media) delete operations. + /// + protected virtual string? RelateParentOnDeleteAlias => null; + protected async Task> MapCreate(ContentCreationModelBase contentCreationModelBase) where TContentCreateResult : ContentCreateResultBase, new() { @@ -202,9 +207,27 @@ private async Task(status, content); } - if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) + if (disabledWhenReferenced) { - return Attempt.FailWithStatus(referenceFailStatus, content); + // When checking if an item is related, we may need to exclude the "relate parent on delete" relation type, as this prevents + // deleting from the recycle bin. + int[]? excludeRelationTypeIds = null; + if (string.IsNullOrWhiteSpace(RelateParentOnDeleteAlias) is false) + { + IRelationType? relateParentOnDeleteRelationType = _relationService.GetRelationTypeByAlias(RelateParentOnDeleteAlias); + if (relateParentOnDeleteRelationType is not null) + { + excludeRelationTypeIds = [relateParentOnDeleteRelationType.Id]; + } + } + + if (_relationService.IsRelated( + content.Id, + RelationDirectionFilter.Child, + excludeRelationTypeIds: excludeRelationTypeIds)) + { + return Attempt.FailWithStatus(referenceFailStatus, content); + } } var userId = await GetUserIdAsync(userKey); diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs index 6cee0e0f5c89..c2b65a097dc5 100644 --- a/src/Umbraco.Core/Services/DocumentUrlService.cs +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -2,9 +2,11 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.PublishedCache; @@ -28,6 +30,7 @@ public class DocumentUrlService : IDocumentUrlService private readonly IDocumentRepository _documentRepository; private readonly ICoreScopeProvider _coreScopeProvider; private readonly GlobalSettings _globalSettings; + private readonly WebRoutingSettings _webRoutingSettings; private readonly UrlSegmentProviderCollection _urlSegmentProviderCollection; private readonly IContentService _contentService; private readonly IShortStringHelper _shortStringHelper; @@ -37,6 +40,7 @@ public class DocumentUrlService : IDocumentUrlService private readonly IDocumentNavigationQueryService _documentNavigationQueryService; private readonly IPublishStatusQueryService _publishStatusQueryService; private readonly IDomainCacheService _domainCacheService; + private readonly IDefaultCultureAccessor _defaultCultureAccessor; private readonly ConcurrentDictionary _cache = new(); private bool _isInitialized; @@ -96,6 +100,7 @@ public UrlSegment(string segment, bool isPrimary) /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")] public DocumentUrlService( ILogger logger, IDocumentUrlRepository documentUrlRepository, @@ -111,12 +116,53 @@ public DocumentUrlService( IDocumentNavigationQueryService documentNavigationQueryService, IPublishStatusQueryService publishStatusQueryService, IDomainCacheService domainCacheService) + :this( + logger, + documentUrlRepository, + documentRepository, + coreScopeProvider, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>(), + urlSegmentProviderCollection, + contentService, + shortStringHelper, + languageService, + keyValueService, + idKeyMap, + documentNavigationQueryService, + publishStatusQueryService, + domainCacheService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public DocumentUrlService( + ILogger logger, + IDocumentUrlRepository documentUrlRepository, + IDocumentRepository documentRepository, + ICoreScopeProvider coreScopeProvider, + IOptions globalSettings, + IOptions webRoutingSettings, + UrlSegmentProviderCollection urlSegmentProviderCollection, + IContentService contentService, + IShortStringHelper shortStringHelper, + ILanguageService languageService, + IKeyValueService keyValueService, + IIdKeyMap idKeyMap, + IDocumentNavigationQueryService documentNavigationQueryService, + IPublishStatusQueryService publishStatusQueryService, + IDomainCacheService domainCacheService, + IDefaultCultureAccessor defaultCultureAccessor) { _logger = logger; _documentUrlRepository = documentUrlRepository; _documentRepository = documentRepository; _coreScopeProvider = coreScopeProvider; _globalSettings = globalSettings.Value; + _webRoutingSettings = webRoutingSettings.Value; _urlSegmentProviderCollection = urlSegmentProviderCollection; _contentService = contentService; _shortStringHelper = shortStringHelper; @@ -126,6 +172,7 @@ public DocumentUrlService( _documentNavigationQueryService = documentNavigationQueryService; _publishStatusQueryService = publishStatusQueryService; _domainCacheService = domainCacheService; + _defaultCultureAccessor = defaultCultureAccessor; } /// @@ -494,6 +541,37 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera scope.Complete(); } + /// + public Guid? GetDocumentKeyByUri(Uri uri, bool isDraft) + { + IEnumerable domains = _domainCacheService.GetAll(false); + DomainAndUri? domain = DomainUtilities.SelectDomain(domains, uri, defaultCulture: _defaultCultureAccessor.DefaultCulture); + + string route; + if (domain is not null) + { + route = domain.ContentId + DomainUtilities.PathRelativeToDomain(domain.Uri, uri.GetAbsolutePathDecoded()); + } + else + { + // If we have configured strict domain matching, and a domain has not been found for the request configured on an ancestor node, + // do not route the content by URL. + if (_webRoutingSettings.UseStrictDomainMatching) + { + return null; + } + + // Default behaviour if strict domain matching is not enabled will be to route under the to the first root node found. + route = uri.GetAbsolutePathDecoded(); + } + + return GetDocumentKeyByRoute( + domain is null ? route : route[domain.ContentId.ToString().Length..], + domain?.Culture, + domain?.ContentId, + isDraft); + } + /// public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft) { diff --git a/src/Umbraco.Core/Services/IDocumentUrlService.cs b/src/Umbraco.Core/Services/IDocumentUrlService.cs index 8020a7726991..eab37488df1c 100644 --- a/src/Umbraco.Core/Services/IDocumentUrlService.cs +++ b/src/Umbraco.Core/Services/IDocumentUrlService.cs @@ -1,5 +1,4 @@ using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Routing; namespace Umbraco.Cms.Core.Services; @@ -64,6 +63,14 @@ IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraf /// The collection of document keys. Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys); + /// + /// Gets a document key by . + /// + /// The uniform resource identifier. + /// Whether to get the url of the draft or published document. + /// The document key, or null if not found. + Guid? GetDocumentKeyByUri(Uri uri, bool isDraft) => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Gets a document key by route. /// diff --git a/src/Umbraco.Core/Services/IPropertyTypeUsageService.cs b/src/Umbraco.Core/Services/IPropertyTypeUsageService.cs index df061ca628c4..5497e575c50a 100644 --- a/src/Umbraco.Core/Services/IPropertyTypeUsageService.cs +++ b/src/Umbraco.Core/Services/IPropertyTypeUsageService.cs @@ -2,6 +2,9 @@ namespace Umbraco.Cms.Core.Services; +/// +/// Defines service methods for querying property type usage. +/// public interface IPropertyTypeUsageService { /// diff --git a/src/Umbraco.Core/Services/IRelationService.cs b/src/Umbraco.Core/Services/IRelationService.cs index 828abc1fd124..7ddae1394ded 100644 --- a/src/Umbraco.Core/Services/IRelationService.cs +++ b/src/Umbraco.Core/Services/IRelationService.cs @@ -297,9 +297,24 @@ public interface IRelationService : IService /// /// Id of an object to check relations for /// Indicates whether to check for relations as parent, child or in either direction. - /// Returns True if any relations exists with the given Id, otherwise False + /// Returns True if any relations exists with the given Id, otherwise False. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")] bool IsRelated(int id, RelationDirectionFilter directionFilter); + /// + /// Checks whether any relations exists for the passed in Id and direction. + /// + /// Id of an object to check relations for + /// Indicates whether to check for relations as parent, child or in either direction. + /// A collection of relation type Ids to include consideration in the relation checks. + /// A collection of relation type Ids to exclude from consideration in the relation checks. + /// If no relation type Ids are provided in includeRelationTypeIds or excludeRelationTypeIds, all relation type Ids are considered. + /// Returns True if any relations exists with the given Id, otherwise False. + bool IsRelated(int id, RelationDirectionFilter directionFilter, int[]? includeRelationTypeIds = null, int[]? excludeRelationTypeIds = null) +#pragma warning disable CS0618 // Type or member is obsolete + => IsRelated(id, directionFilter); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Checks whether two items are related /// diff --git a/src/Umbraco.Core/Services/MediaEditingService.cs b/src/Umbraco.Core/Services/MediaEditingService.cs index 6947e1dc5d0e..ec9177c150bd 100644 --- a/src/Umbraco.Core/Services/MediaEditingService.cs +++ b/src/Umbraco.Core/Services/MediaEditingService.cs @@ -43,6 +43,9 @@ public MediaEditingService( contentTypeFilters) => _logger = logger; + /// + protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias; + public Task GetAsync(Guid key) { IMedia? media = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/MetricsConsentService.cs b/src/Umbraco.Core/Services/MetricsConsentService.cs index 3a0cc46a0d85..143d3ca3aa49 100644 --- a/src/Umbraco.Core/Services/MetricsConsentService.cs +++ b/src/Umbraco.Core/Services/MetricsConsentService.cs @@ -39,11 +39,10 @@ public TelemetryLevel GetConsentLevel() return analyticsLevel; } - public async Task SetConsentLevelAsync(TelemetryLevel telemetryLevel) + public Task SetConsentLevelAsync(TelemetryLevel telemetryLevel) { - IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser ?? await _userService.GetAsync(Constants.Security.SuperUserKey); - - _logger.LogInformation("Telemetry level set to {telemetryLevel} by {username}", telemetryLevel, currentUser?.Username); + _logger.LogInformation("Telemetry level set to {telemetryLevel}", telemetryLevel); _keyValueService.SetValue(Key, telemetryLevel.ToString()); + return Task.CompletedTask; } } diff --git a/src/Umbraco.Core/Services/NotificationService.cs b/src/Umbraco.Core/Services/NotificationService.cs index d12ef6c6dfb6..89c6e6e4ed9b 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -552,7 +552,7 @@ private void Process(BlockingCollection notificationRequest { ThreadPool.QueueUserWorkItem(state => { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Begin processing notifications."); } @@ -564,9 +564,9 @@ private void Process(BlockingCollection notificationRequest { try { - _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter() + _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification, false, null).GetAwaiter() .GetResult(); - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email); } diff --git a/src/Umbraco.Core/Services/PropertyTypeUsageService.cs b/src/Umbraco.Core/Services/PropertyTypeUsageService.cs index d22bdeb44048..579a338ecdaa 100644 --- a/src/Umbraco.Core/Services/PropertyTypeUsageService.cs +++ b/src/Umbraco.Core/Services/PropertyTypeUsageService.cs @@ -4,19 +4,25 @@ namespace Umbraco.Cms.Core.Services; +/// public class PropertyTypeUsageService : IPropertyTypeUsageService { private readonly IPropertyTypeUsageRepository _propertyTypeUsageRepository; - private readonly IContentTypeService _contentTypeService; private readonly ICoreScopeProvider _scopeProvider; + // TODO (V18): Remove IContentTypeService parameter from constructor. + + /// + /// Initializes a new instance of the class. + /// public PropertyTypeUsageService( IPropertyTypeUsageRepository propertyTypeUsageRepository, +#pragma warning disable IDE0060 // Remove unused parameter IContentTypeService contentTypeService, +#pragma warning restore IDE0060 // Remove unused parameter ICoreScopeProvider scopeProvider) { _propertyTypeUsageRepository = propertyTypeUsageRepository; - _contentTypeService = contentTypeService; _scopeProvider = scopeProvider; } diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs index 63d6d657fc80..43b954a7f7b5 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -69,7 +69,9 @@ public bool IsDocumentPublished(Guid documentKey, string culture) if (_publishedCultures.TryGetValue(documentKey, out ISet? publishedCultures)) { - return publishedCultures.Contains(culture, StringComparer.InvariantCultureIgnoreCase); + // If "*" is provided as the culture, we consider this as "published in any culture". This aligns + // with behaviour in Umbraco 13. + return culture == Constants.System.InvariantCulture || publishedCultures.Contains(culture, StringComparer.InvariantCultureIgnoreCase); } _logger.LogDebug("Document {DocumentKey} not found in the publish status cache", documentKey); diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs index 860b4cb2f3e2..fce1eb949c75 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; @@ -40,13 +40,14 @@ public IEnumerable FilterAvailable(IEnumerable candidat _publishStatusQueryService.IsDocumentPublished(key, culture) && _publishStatusQueryService.HasPublishedAncestorPath(key)); - return WhereIsInvariantOrHasCulture(candidateKeys, culture, preview).ToArray(); + return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray(); } - private IEnumerable WhereIsInvariantOrHasCulture(IEnumerable keys, string culture, bool preview) + private IEnumerable WhereIsInvariantOrHasCultureOrRequestedAllCultures(IEnumerable keys, string culture, bool preview) => keys .Select(key => _publishedContentCache.GetById(preview, key)) .WhereNotNull() - .Where(content => content.ContentType.VariesByCulture() is false + .Where(content => culture == Constants.System.InvariantCulture + || content.ContentType.VariesByCulture() is false || content.Cultures.ContainsKey(culture)); } diff --git a/src/Umbraco.Core/Services/RelationService.cs b/src/Umbraco.Core/Services/RelationService.cs index 35adb2e217bc..ed1d03df7029 100644 --- a/src/Umbraco.Core/Services/RelationService.cs +++ b/src/Umbraco.Core/Services/RelationService.cs @@ -485,11 +485,14 @@ public bool HasRelations(IRelationType relationType) return _relationRepository.Get(query).Any(); } - /// - public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any); + [Obsolete("No longer used in Umbraco, please the overload taking all parameters. Scheduled for removal in Umbraco 19.")] + public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any, null, null); + + [Obsolete("Please the overload taking all parameters. Scheduled for removal in Umbraco 18.")] + public bool IsRelated(int id, RelationDirectionFilter directionFilter) => IsRelated(id, directionFilter, null, null); /// - public bool IsRelated(int id, RelationDirectionFilter directionFilter) + public bool IsRelated(int id, RelationDirectionFilter directionFilter, int[]? includeRelationTypeIds = null, int[]? excludeRelationTypeIds = null) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IQuery query = Query(); @@ -502,6 +505,16 @@ public bool IsRelated(int id, RelationDirectionFilter directionFilter) _ => throw new ArgumentOutOfRangeException(nameof(directionFilter)), }; + if (includeRelationTypeIds is not null && includeRelationTypeIds.Length > 0) + { + query = query.WhereIn(x => x.RelationTypeId, includeRelationTypeIds); + } + + if (excludeRelationTypeIds is not null && excludeRelationTypeIds.Length > 0) + { + query = query.WhereNotIn(x => x.RelationTypeId, excludeRelationTypeIds); + } + return _relationRepository.Get(query).Any(); } diff --git a/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs b/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs index 14dc4db63214..f47501c94b1d 100644 --- a/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs +++ b/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs @@ -6,9 +6,11 @@ namespace Umbraco.Cms.Core.Cache.PropertyEditors; internal sealed class BlockEditorElementTypeCache : IBlockEditorElementTypeCache { + private const string CacheKey = $"{nameof(BlockEditorElementTypeCache)}_ElementTypes"; private readonly IContentTypeService _contentTypeService; private readonly AppCaches _appCaches; + public BlockEditorElementTypeCache(IContentTypeService contentTypeService, AppCaches appCaches) { _contentTypeService = contentTypeService; @@ -20,15 +22,15 @@ public BlockEditorElementTypeCache(IContentTypeService contentTypeService, AppCa public IEnumerable GetAll() { // TODO: make this less dumb; don't fetch all elements, only fetch the items that aren't yet in the cache and amend the cache as more elements are loaded - - const string cacheKey = $"{nameof(BlockEditorElementTypeCache)}_ElementTypes"; - IEnumerable? cachedElements = _appCaches.RequestCache.GetCacheItem>(cacheKey); + IEnumerable? cachedElements = _appCaches.RequestCache.GetCacheItem>(CacheKey); if (cachedElements is null) { cachedElements = _contentTypeService.GetAllElementTypes(); - _appCaches.RequestCache.Set(cacheKey, cachedElements); + _appCaches.RequestCache.Set(CacheKey, cachedElements); } return cachedElements; } + + public void ClearAll() => _appCaches.RequestCache.Remove(CacheKey); } diff --git a/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs b/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs index f99d2ff8757f..48142de97ffd 100644 --- a/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs +++ b/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs @@ -6,4 +6,5 @@ public interface IBlockEditorElementTypeCache { IEnumerable GetMany(IEnumerable keys); IEnumerable GetAll(); + void ClearAll() { } } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index c9f8db1154c0..d9a9c025eb51 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -370,12 +370,16 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/Examine/ContentIndexPopulator.cs b/src/Umbraco.Infrastructure/Examine/ContentIndexPopulator.cs index eb9e9f135fdd..e912728feef7 100644 --- a/src/Umbraco.Infrastructure/Examine/ContentIndexPopulator.cs +++ b/src/Umbraco.Infrastructure/Examine/ContentIndexPopulator.cs @@ -1,5 +1,9 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Services; @@ -20,11 +24,23 @@ public class ContentIndexPopulator : IndexPopulator private readonly bool _publishedValuesOnly; private readonly IUmbracoDatabaseFactory _umbracoDatabaseFactory; + private IndexingSettings _indexingSettings; + /// /// This is a static query, it's parameters don't change so store statically /// private IQuery? _publishedQuery; + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] + public ContentIndexPopulator( + ILogger logger, + IContentService contentService, + IUmbracoDatabaseFactory umbracoDatabaseFactory, + IContentValueSetBuilder contentValueSetBuilder) + : this(logger, false, null, contentService, umbracoDatabaseFactory, contentValueSetBuilder, StaticServiceProvider.Instance.GetRequiredService>()) + { + } + /// /// Default constructor to lookup all content data /// @@ -32,8 +48,21 @@ public ContentIndexPopulator( ILogger logger, IContentService contentService, IUmbracoDatabaseFactory umbracoDatabaseFactory, - IContentValueSetBuilder contentValueSetBuilder) - : this(logger, false, null, contentService, umbracoDatabaseFactory, contentValueSetBuilder) + IContentValueSetBuilder contentValueSetBuilder, + IOptionsMonitor indexingSettings) + : this(logger, false, null, contentService, umbracoDatabaseFactory, contentValueSetBuilder, indexingSettings) + { + } + + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] + public ContentIndexPopulator( + ILogger logger, + bool publishedValuesOnly, + int? parentId, + IContentService contentService, + IUmbracoDatabaseFactory umbracoDatabaseFactory, + IValueSetBuilder contentValueSetBuilder) + : this(logger, publishedValuesOnly, parentId, contentService, umbracoDatabaseFactory, contentValueSetBuilder, StaticServiceProvider.Instance.GetRequiredService>()) { } @@ -46,7 +75,8 @@ public ContentIndexPopulator( int? parentId, IContentService contentService, IUmbracoDatabaseFactory umbracoDatabaseFactory, - IValueSetBuilder contentValueSetBuilder) + IValueSetBuilder contentValueSetBuilder, + IOptionsMonitor indexingSettings) { _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); _umbracoDatabaseFactory = umbracoDatabaseFactory ?? throw new ArgumentNullException(nameof(umbracoDatabaseFactory)); @@ -54,6 +84,12 @@ public ContentIndexPopulator( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _publishedValuesOnly = publishedValuesOnly; _parentId = parentId; + _indexingSettings = indexingSettings.CurrentValue; + + indexingSettings.OnChange(change => + { + _indexingSettings = change; + }); } private IQuery PublishedQuery => _publishedQuery ??= @@ -75,7 +111,6 @@ protected override void PopulateIndexes(IReadOnlyList indexes) return; } - const int pageSize = 10000; var pageIndex = 0; var contentParentId = -1; @@ -86,11 +121,11 @@ protected override void PopulateIndexes(IReadOnlyList indexes) if (_publishedValuesOnly) { - IndexPublishedContent(contentParentId, pageIndex, pageSize, indexes); + IndexPublishedContent(contentParentId, pageIndex, _indexingSettings.BatchSize, indexes); } else { - IndexAllContent(contentParentId, pageIndex, pageSize, indexes); + IndexAllContent(contentParentId, pageIndex, _indexingSettings.BatchSize, indexes); } } diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexHelper.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexHelper.cs index d7f2fc0c69af..2747773e1134 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexHelper.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexHelper.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Services; @@ -14,22 +16,33 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe private readonly IUmbracoDatabaseFactory _umbracoDatabaseFactory; private DeliveryApiSettings _deliveryApiSettings; + private IndexingSettings _indexingSettings; + + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] public DeliveryApiContentIndexHelper( IContentService contentService, IUmbracoDatabaseFactory umbracoDatabaseFactory, IOptionsMonitor deliveryApiSettings) + : this(contentService, umbracoDatabaseFactory, deliveryApiSettings, StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public DeliveryApiContentIndexHelper( + IContentService contentService, + IUmbracoDatabaseFactory umbracoDatabaseFactory, + IOptionsMonitor deliveryApiSettings, + IOptionsMonitor indexingSettings) { _contentService = contentService; _umbracoDatabaseFactory = umbracoDatabaseFactory; _deliveryApiSettings = deliveryApiSettings.CurrentValue; + _indexingSettings = indexingSettings.CurrentValue; deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); + indexingSettings.OnChange(settings => _indexingSettings = settings); } public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action actionToPerform) - { - const int pageSize = 10000; - EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize); - } + => EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, _indexingSettings.BatchSize); internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action actionToPerform, int pageSize) { diff --git a/src/Umbraco.Infrastructure/Examine/MediaIndexPopulator.cs b/src/Umbraco.Infrastructure/Examine/MediaIndexPopulator.cs index 6f4a4db4a3a9..b9872da5c8ae 100644 --- a/src/Umbraco.Infrastructure/Examine/MediaIndexPopulator.cs +++ b/src/Umbraco.Infrastructure/Examine/MediaIndexPopulator.cs @@ -1,5 +1,9 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -15,23 +19,43 @@ public class MediaIndexPopulator : IndexPopulator private readonly IValueSetBuilder _mediaValueSetBuilder; private readonly int? _parentId; + private IndexingSettings _indexingSettings; + + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] + public MediaIndexPopulator(ILogger logger, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder) + : this(logger, null, mediaService, mediaValueSetBuilder, StaticServiceProvider.Instance.GetRequiredService>()) + { + } + /// /// Default constructor to lookup all content data /// - public MediaIndexPopulator(ILogger logger, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder) - : this(logger, null, mediaService, mediaValueSetBuilder) + public MediaIndexPopulator(ILogger logger, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder, IOptionsMonitor indexingSettings) + : this(logger, null, mediaService, mediaValueSetBuilder, indexingSettings) + { + } + + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] + public MediaIndexPopulator(ILogger logger, int? parentId, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder) + : this(logger, parentId, mediaService, mediaValueSetBuilder, StaticServiceProvider.Instance.GetRequiredService>()) { } /// /// Optional constructor allowing specifying custom query parameters /// - public MediaIndexPopulator(ILogger logger, int? parentId, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder) + public MediaIndexPopulator(ILogger logger, int? parentId, IMediaService mediaService, IValueSetBuilder mediaValueSetBuilder, IOptionsMonitor indexingSettings) { _logger = logger; _parentId = parentId; _mediaService = mediaService; _mediaValueSetBuilder = mediaValueSetBuilder; + _indexingSettings = indexingSettings.CurrentValue; + + indexingSettings.OnChange(change => + { + _indexingSettings = change; + }); } protected override void PopulateIndexes(IReadOnlyList indexes) @@ -46,7 +70,6 @@ protected override void PopulateIndexes(IReadOnlyList indexes) return; } - const int pageSize = 10000; var pageIndex = 0; var mediaParentId = -1; @@ -60,7 +83,7 @@ protected override void PopulateIndexes(IReadOnlyList indexes) do { - media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, pageSize, out _).ToArray(); + media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray(); // ReSharper disable once PossibleMultipleEnumeration foreach (IIndex index in indexes) @@ -70,6 +93,6 @@ protected override void PopulateIndexes(IReadOnlyList indexes) pageIndex++; } - while (media.Length == pageSize); + while (media.Length == _indexingSettings.BatchSize); } } diff --git a/src/Umbraco.Infrastructure/Examine/PublishedContentIndexPopulator.cs b/src/Umbraco.Infrastructure/Examine/PublishedContentIndexPopulator.cs index 67d59d02d9c8..4f07a7283f03 100644 --- a/src/Umbraco.Infrastructure/Examine/PublishedContentIndexPopulator.cs +++ b/src/Umbraco.Infrastructure/Examine/PublishedContentIndexPopulator.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence; @@ -15,6 +17,7 @@ namespace Umbraco.Cms.Infrastructure.Examine; /// public class PublishedContentIndexPopulator : ContentIndexPopulator { + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V19.")] public PublishedContentIndexPopulator( ILogger logger, IContentService contentService, @@ -23,4 +26,14 @@ public PublishedContentIndexPopulator( : base(logger, true, null, contentService, umbracoDatabaseFactory, contentValueSetBuilder) { } + + public PublishedContentIndexPopulator( + ILogger logger, + IContentService contentService, + IUmbracoDatabaseFactory umbracoDatabaseFactory, + IPublishedContentValueSetBuilder contentValueSetBuilder, + IOptionsMonitor indexingSettings) + : base(logger, true, null, contentService, umbracoDatabaseFactory, contentValueSetBuilder, indexingSettings) + { + } } diff --git a/src/Umbraco.Infrastructure/Installer/Steps/CreateUserStep.cs b/src/Umbraco.Infrastructure/Installer/Steps/CreateUserStep.cs index b4fe7b23b9b8..c2d5b6dd6f6e 100644 --- a/src/Umbraco.Infrastructure/Installer/Steps/CreateUserStep.cs +++ b/src/Umbraco.Infrastructure/Installer/Steps/CreateUserStep.cs @@ -1,10 +1,11 @@ -using System.Collections.Specialized; using System.Data.Common; -using System.Text; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Installer; using Umbraco.Cms.Core.Models.Installer; using Umbraco.Cms.Core.Models.Membership; @@ -32,7 +33,9 @@ public class CreateUserStep : StepBase, IInstallStep private readonly IDbProviderFactoryCreator _dbProviderFactoryCreator; private readonly IMetricsConsentService _metricsConsentService; private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + [Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")] public CreateUserStep( IUserService userService, DatabaseBuilder databaseBuilder, @@ -44,71 +47,132 @@ public CreateUserStep( IDbProviderFactoryCreator dbProviderFactoryCreator, IMetricsConsentService metricsConsentService, IJsonSerializer jsonSerializer) + : this( + userService, + databaseBuilder, + httpClientFactory, + securitySettings, + connectionStrings, + cookieManager, + userManager, + dbProviderFactoryCreator, + metricsConsentService, + jsonSerializer, + StaticServiceProvider.Instance.GetRequiredService>()) { - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); - _databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder)); + } + + public CreateUserStep( + IUserService userService, + DatabaseBuilder databaseBuilder, + IHttpClientFactory httpClientFactory, + IOptions securitySettings, + IOptionsMonitor connectionStrings, + ICookieManager cookieManager, + IBackOfficeUserManager userManager, + IDbProviderFactoryCreator dbProviderFactoryCreator, + IMetricsConsentService metricsConsentService, + IJsonSerializer jsonSerializer, + ILogger logger) + { + _userService = userService; + _databaseBuilder = databaseBuilder; _httpClientFactory = httpClientFactory; - _securitySettings = securitySettings.Value ?? throw new ArgumentNullException(nameof(securitySettings)); + _securitySettings = securitySettings.Value; _connectionStrings = connectionStrings; _cookieManager = cookieManager; - _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); - _dbProviderFactoryCreator = dbProviderFactoryCreator ?? throw new ArgumentNullException(nameof(dbProviderFactoryCreator)); + _userManager = userManager; + _dbProviderFactoryCreator = dbProviderFactoryCreator; _metricsConsentService = metricsConsentService; _jsonSerializer = jsonSerializer; + _logger = logger; } public async Task> ExecuteAsync(InstallData model) { - IUser? admin = _userService.GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult(); - if (admin is null) - { - return FailWithMessage("Could not find the super user"); - } + IUser? admin = await _userService.GetAsync(Constants.Security.SuperUserKey); + if (admin is null) + { + return FailWithMessage("Could not find the super user"); + } - UserInstallData user = model.User; - admin.Email = user.Email.Trim(); - admin.Name = user.Name.Trim(); - admin.Username = user.Email.Trim(); + UserInstallData user = model.User; + admin.Email = user.Email.Trim(); + admin.Name = user.Name.Trim(); + admin.Username = user.Email.Trim(); - _userService.Save(admin); + _userService.Save(admin); - BackOfficeIdentityUser? membershipUser = await _userManager.FindByIdAsync(Constants.Security.SuperUserIdAsString); - if (membershipUser == null) - { - return FailWithMessage( - $"No user found in membership provider with id of {Constants.Security.SuperUserIdAsString}."); - } + BackOfficeIdentityUser? membershipUser = await _userManager.FindByIdAsync(Constants.Security.SuperUserIdAsString); + if (membershipUser == null) + { + return FailWithMessage( + $"No user found in membership provider with id of {Constants.Security.SuperUserIdAsString}."); + } - // To change the password here we actually need to reset it since we don't have an old one to use to change - var resetToken = await _userManager.GeneratePasswordResetTokenAsync(membershipUser); - if (string.IsNullOrWhiteSpace(resetToken)) + // To change the password here we actually need to reset it since we don't have an old one to use to change + var resetToken = await _userManager.GeneratePasswordResetTokenAsync(membershipUser); + if (string.IsNullOrWhiteSpace(resetToken)) + { + return FailWithMessage("Could not reset password: unable to generate internal reset token"); + } + + IdentityResult resetResult = await _userManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, user.Password.Trim()); + if (!resetResult.Succeeded) + { + return FailWithMessage("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); + } + + await _metricsConsentService.SetConsentLevelAsync(model.TelemetryLevel); + + if (model.User.SubscribeToNewsletter) + { + const string EmailCollectorUrl = "https://emailcollector.umbraco.io/api/EmailProxy"; + + var emailModel = new EmailModel + { + Name = admin.Name, + Email = admin.Email, + UserGroup = [Constants.Security.AdminGroupAlias], + }; + + HttpClient httpClient = _httpClientFactory.CreateClient(); + using var content = new StringContent(_jsonSerializer.Serialize(emailModel), System.Text.Encoding.UTF8, "application/json"); + try { - return FailWithMessage("Could not reset password: unable to generate internal reset token"); + // Set a reasonable timeout of 5 seconds for web request to save subscriber. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + HttpResponseMessage response = await httpClient.PostAsync(EmailCollectorUrl, content, cts.Token); + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Successfully subscribed the user created on installation to the Umbraco newsletter."); + } + else + { + _logger.LogWarning("Failed to subscribe the user created on installation to the Umbraco newsletter. Status code: {StatusCode}", response.StatusCode); + } } - - IdentityResult resetResult = await _userManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, user.Password.Trim()); - if (!resetResult.Succeeded) + catch (Exception ex) { - return FailWithMessage("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); + // Log and move on if a failure occurs, we don't want to block installation for this. + _logger.LogError(ex, "Exception occurred while trying to subscribe the user created on installation to the Umbraco newsletter."); } - await _metricsConsentService.SetConsentLevelAsync(model.TelemetryLevel); + } - if (model.User.SubscribeToNewsletter) - { - var values = new NameValueCollection { { "name", admin.Name }, { "email", admin.Email } }; - var content = new StringContent(_jsonSerializer.Serialize(values), Encoding.UTF8, "application/json"); + return Success(); + } - HttpClient httpClient = _httpClientFactory.CreateClient(); + /// + /// Model used to subscribe to the newsletter. Aligns with EmailModel defined in Umbraco.EmailMarketing. + /// + private class EmailModel + { + public required string Name { get; init; } - try - { - HttpResponseMessage response = httpClient.PostAsync("https://shop.umbraco.com/base/Ecom/SubmitEmail/installer.aspx", content).Result; - } - catch { /* fail in silence */ } - } + public required string Email { get; init; } - return Success(); + public required List UserGroup { get; init; } } /// diff --git a/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs b/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs index 15ae1d0f4992..03687f6be832 100644 --- a/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs +++ b/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs @@ -15,28 +15,44 @@ namespace Umbraco.Cms.Infrastructure.Mail public class BasicSmtpEmailSenderClient : IEmailSenderClient { private readonly GlobalSettings _globalSettings; + + /// public BasicSmtpEmailSenderClient(IOptionsMonitor globalSettings) - { - _globalSettings = globalSettings.CurrentValue; - } + => _globalSettings = globalSettings.CurrentValue; + /// public async Task SendAsync(EmailMessage message) + => await SendAsync(message, null); + + /// + public async Task SendAsync(EmailMessage message, TimeSpan? expires) { using var client = new SmtpClient(); await client.ConnectAsync( _globalSettings.Smtp!.Host, - _globalSettings.Smtp.Port, + _globalSettings.Smtp.Port, (SecureSocketOptions)(int)_globalSettings.Smtp.SecureSocketOptions); if (!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Username) && - !string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password)) + !string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password)) { await client.AuthenticateAsync(_globalSettings.Smtp.Username, _globalSettings.Smtp.Password); } var mimeMessage = message.ToMimeMessage(_globalSettings.Smtp!.From); + if (_globalSettings.IsSmtpExpiryConfigured) + { + expires ??= _globalSettings.Smtp.EmailExpiration; + } + + if (expires.HasValue) + { + // `Expires` header needs to be in RFC 1123/2822 compatible format + mimeMessage.Headers.Add("Expires", DateTimeOffset.UtcNow.Add(expires.GetValueOrDefault()).ToString("R")); + } + if (_globalSettings.Smtp.DeliveryMethod == SmtpDeliveryMethod.Network) { await client.SendAsync(mimeMessage); diff --git a/src/Umbraco.Infrastructure/Mail/EmailSender.cs b/src/Umbraco.Infrastructure/Mail/EmailSender.cs index 618323bcd109..e88737e7efd0 100644 --- a/src/Umbraco.Infrastructure/Mail/EmailSender.cs +++ b/src/Umbraco.Infrastructure/Mail/EmailSender.cs @@ -17,7 +17,7 @@ namespace Umbraco.Cms.Infrastructure.Mail; /// -/// A utility class for sending emails +/// A utility class for sending emails. /// public class EmailSender : IEmailSender { @@ -28,6 +28,9 @@ public class EmailSender : IEmailSender private GlobalSettings _globalSettings; private readonly IEmailSenderClient _emailSenderClient; + /// + /// Initializes a new instance of the class. + /// public EmailSender( ILogger logger, IOptionsMonitor globalSettings, @@ -44,28 +47,28 @@ public EmailSender( globalSettings.OnChange(x => _globalSettings = x); } - /// - /// Sends the message async - /// - /// + /// public async Task SendAsync(EmailMessage message, string emailType) => - await SendAsyncInternal(message, emailType, false); + await SendAsyncInternal(message, emailType, false, null); + /// public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification) => - await SendAsyncInternal(message, emailType, enableNotification); + await SendAsyncInternal(message, emailType, enableNotification, null); - /// - /// Returns true if the application should be able to send a required application email - /// + /// + public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) => + await SendAsyncInternal(message, emailType, enableNotification, expires); + + /// /// /// We assume this is possible if either an event handler is registered or an smtp server is configured - /// or a pickup directory location is configured + /// or a pickup directory location is configured. /// public bool CanSendRequiredEmail() => _globalSettings.IsSmtpServerConfigured || _globalSettings.IsPickupDirectoryLocationConfigured || _notificationHandlerRegistered; - private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification) + private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) { if (enableNotification) { @@ -76,7 +79,7 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo // if a handler handled sending the email then don't continue. if (notification.IsHandled) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( "The email sending for {Subject} was handled by a notification handler", @@ -88,7 +91,7 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo if (!_globalSettings.IsSmtpServerConfigured && !_globalSettings.IsPickupDirectoryLocationConfigured) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( "Could not send email for {Subject}. It was not handled by a notification handler and there is no SMTP configured.", @@ -145,7 +148,6 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo while (true); } - await _emailSenderClient.SendAsync(message); + await _emailSenderClient.SendAsync(message, expires); } - } diff --git a/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs b/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs index 10dd5284c40f..3749d71bed3e 100644 --- a/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs +++ b/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs @@ -3,15 +3,25 @@ namespace Umbraco.Cms.Infrastructure.Mail.Interfaces { /// - /// Client for sending an email from a MimeMessage + /// Client for sending an email from a MimeMessage. /// public interface IEmailSenderClient { /// - /// Sends the email message + /// Sends the email message. /// - /// - /// + /// The to send. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")] public Task SendAsync(EmailMessage message); + + /// + /// Sends the email message with an expiration date. + /// + /// The to send. + /// An optional time for expiry. + public Task SendAsync(EmailMessage message, TimeSpan? expires) +#pragma warning disable CS0618 // Type or member is obsolete + => SendAsync(message); +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/Umbraco.Infrastructure/Mapping/UmbracoDefaultMapper.cs b/src/Umbraco.Infrastructure/Mapping/UmbracoDefaultMapper.cs index 986bb19d39d8..0afb677ee9ab 100644 --- a/src/Umbraco.Infrastructure/Mapping/UmbracoDefaultMapper.cs +++ b/src/Umbraco.Infrastructure/Mapping/UmbracoDefaultMapper.cs @@ -1,30 +1,76 @@ -using System.Globalization; +using System.Globalization; using NPoco; namespace Umbraco.Cms.Core.Mapping; +/// +/// Provides default type conversion logic for mapping Umbraco database values to .NET types, extending the base mapping +/// behavior with support for additional types such as decimal, DateOnly, and TimeOnly. +/// public class UmbracoDefaultMapper : DefaultMapper { + /// public override Func GetFromDbConverter(Type destType, Type sourceType) { if (destType == typeof(decimal)) { - return value => - { - var result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); - return result; - }; + return value => Convert.ToDecimal(value, CultureInfo.InvariantCulture); } if (destType == typeof(decimal?)) { - return value => - { - var result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); - return result; - }; + return value => Convert.ToDecimal(value, CultureInfo.InvariantCulture); + } + + if(IsDateOnlyType(destType)) + { + return value => ConvertToDateOnly(value, IsNullableType(destType)); + } + + if (IsTimeOnlyType(destType)) + { + return value => ConvertToTimeOnly(value, IsNullableType(destType)); } return base.GetFromDbConverter(destType, sourceType); } + + private static bool IsDateOnlyType(Type type) => + type == typeof(DateOnly) || type == typeof(DateOnly?); + + private static bool IsTimeOnlyType(Type type) => + type == typeof(TimeOnly) || type == typeof(TimeOnly?); + + private static bool IsNullableType(Type type) => + Nullable.GetUnderlyingType(type) != null; + + private static object? ConvertToDateOnly(object? value, bool isNullable) + { + if (value is null) + { + return isNullable ? null : default(DateOnly); + } + + if (value is DateTime dt) + { + return DateOnly.FromDateTime(dt); + } + + return DateOnly.Parse(value.ToString()!); + } + + private static object? ConvertToTimeOnly(object? value, bool isNullable) + { + if (value is null) + { + return isNullable ? null : default(TimeOnly); + } + + if (value is DateTime dt) + { + return TimeOnly.FromDateTime(dt); + } + + return TimeOnly.Parse(value.ToString()!); + } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 7a781b889a46..8bdddc0a55a7 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -139,5 +139,9 @@ protected virtual void DefinePlan() To("{263075BF-F18A-480D-92B4-4947D2EAB772}"); To("26179D88-58CE-4C92-B4A4-3CBA6E7188AC"); To("{8B2C830A-4FFB-4433-8337-8649B0BF52C8}"); + + // To 18.0.0 + // TODO (V18): Enable on 18 branch + //// To("{74332C49-B279-4945-8943-F8F00B1F5949}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs new file mode 100644 index 000000000000..23d683ee01e7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs @@ -0,0 +1,436 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0; + +public class MigrateSingleBlockList : AsyncMigrationBase +{ + private readonly IUmbracoContextFactory _umbracoContextFactory; + private readonly ILanguageService _languageService; + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IDataTypeService _dataTypeService; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly SingleBlockListProcessor _singleBlockListProcessor; + private readonly IJsonSerializer _jsonSerializer; + private readonly SingleBlockListConfigurationCache _blockListConfigurationCache; + private readonly IBlockEditorElementTypeCache _elementTypeCache; + private readonly AppCaches _appCaches; + private readonly ILogger _logger; + private readonly IDataValueEditor _dummySingleBlockValueEditor; + + public MigrateSingleBlockList( + IMigrationContext context, + IUmbracoContextFactory umbracoContextFactory, + ILanguageService languageService, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IDataTypeService dataTypeService, + ILogger logger, + ICoreScopeProvider coreScopeProvider, + SingleBlockListProcessor singleBlockListProcessor, + IJsonSerializer jsonSerializer, + SingleBlockListConfigurationCache blockListConfigurationCache, + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory, + IBlockEditorElementTypeCache elementTypeCache, + AppCaches appCaches) + : base(context) + { + _umbracoContextFactory = umbracoContextFactory; + _languageService = languageService; + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _dataTypeService = dataTypeService; + _logger = logger; + _coreScopeProvider = coreScopeProvider; + _singleBlockListProcessor = singleBlockListProcessor; + _jsonSerializer = jsonSerializer; + _blockListConfigurationCache = blockListConfigurationCache; + _elementTypeCache = elementTypeCache; + _appCaches = appCaches; + + _dummySingleBlockValueEditor = new SingleBlockPropertyEditor(dataValueEditorFactory, jsonSerializer, ioHelper, blockValuePropertyIndexValueFactory).GetValueEditor(); + } + + protected override async Task MigrateAsync() + { + // gets filled by all registered ITypedSingleBlockListProcessor + IEnumerable propertyEditorAliases = _singleBlockListProcessor.GetSupportedPropertyEditorAliases(); + + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + var languagesById = (await _languageService.GetAllAsync()) + .ToDictionary(language => language.Id); + + IEnumerable allContentTypes = _contentTypeService.GetAll(); + IEnumerable contentPropertyTypes = allContentTypes + .SelectMany(ct => ct.PropertyTypes); + + IMediaType[] allMediaTypes = _mediaTypeService.GetAll().ToArray(); + IEnumerable mediaPropertyTypes = allMediaTypes + .SelectMany(ct => ct.PropertyTypes); + + // get all relevantPropertyTypes + var relevantPropertyEditors = + contentPropertyTypes.Concat(mediaPropertyTypes).DistinctBy(pt => pt.Id) + .Where(pt => propertyEditorAliases.Contains(pt.PropertyEditorAlias)) + .GroupBy(pt => pt.PropertyEditorAlias) + .ToDictionary(group => group.Key, group => group.ToArray()); + + // populate the cache to limit amount of db locks in recursion logic. + var blockListsConfiguredAsSingleCount = await _blockListConfigurationCache.Populate(); + + if (blockListsConfiguredAsSingleCount == 0) + { + _logger.LogInformation( + "No blocklist were configured as single, nothing to do."); + return; + } + + _logger.LogInformation( + "Found {blockListsConfiguredAsSingleCount} number of blockListConfigurations with UseSingleBlockMode set to true", + blockListsConfiguredAsSingleCount); + + // we want to batch actual update calls to the database, so we are grouping them by propertyEditorAlias + // and again by propertyType(dataType). + var updateItemsByPropertyEditorAlias = new Dictionary>>(); + + // For each propertyEditor, collect and process all propertyTypes and their propertyData + foreach (var propertyEditorAlias in propertyEditorAliases) + { + if (relevantPropertyEditors.TryGetValue(propertyEditorAlias, out IPropertyType[]? propertyTypes) is false) + { + continue; + } + + _logger.LogInformation( + "Migration starting for all properties of type: {propertyEditorAlias}", + propertyEditorAlias); + Dictionary> updateItemsByPropertyType = await ProcessPropertyTypesAsync(propertyTypes, languagesById); + if (updateItemsByPropertyType.Count < 1) + { + _logger.LogInformation( + "No properties have been found to migrate for {propertyEditorAlias}", + propertyEditorAlias); + return; + } + + updateItemsByPropertyEditorAlias[propertyEditorAlias] = updateItemsByPropertyType; + } + + // update the configuration of all propertyTypes + var singleBlockListDataTypesIds = _blockListConfigurationCache.CachedDataTypes.ToList().Select(type => type.Id).ToList(); + + string updateSql = $@" +UPDATE umbracoDataType +SET propertyEditorAlias = '{Constants.PropertyEditors.Aliases.SingleBlock}', + propertyEditorUiAlias = 'Umb.PropertyEditorUi.SingleBlock' +WHERE nodeId IN (@0)"; + await Database.ExecuteAsync(updateSql, singleBlockListDataTypesIds); + + // we need to clear the elementTypeCache so the second part of the migration can work with the update dataTypes + // and also the isolated/runtime Caches as that is what its build from in the default implementation + _elementTypeCache.ClearAll(); + _appCaches.IsolatedCaches.ClearAllCaches(); + _appCaches.RuntimeCache.Clear(); + RebuildCache = true; + + // now that we have updated the configuration of all propertyTypes, we can save the updated propertyTypes + foreach (string propertyEditorAlias in updateItemsByPropertyEditorAlias.Keys) + { + if (await SavePropertyTypes(updateItemsByPropertyEditorAlias[propertyEditorAlias])) + { + _logger.LogInformation( + "Migration succeeded for all properties of type: {propertyEditorAlias}", + propertyEditorAlias); + } + else + { + _logger.LogError( + "Migration failed for one or more properties of type: {propertyEditorAlias}", + propertyEditorAlias); + } + } + } + + private async Task>> ProcessPropertyTypesAsync(IPropertyType[] propertyTypes, IDictionary languagesById) + { + var updateItemsByPropertyType = new Dictionary>(); + foreach (IPropertyType propertyType in propertyTypes) + { + // make sure the passed in data is valid and can be processed + IDataType dataType = await _dataTypeService.GetAsync(propertyType.DataTypeKey) + ?? throw new InvalidOperationException("The data type could not be fetched."); + IDataValueEditor valueEditor = dataType.Editor?.GetValueEditor() + ?? throw new InvalidOperationException( + "The data type value editor could not be obtained."); + + // fetch all the propertyData for the current propertyType + Sql sql = Sql() + .Select() + .From() + .InnerJoin() + .On((propertyData, contentVersion) => + propertyData.VersionId == contentVersion.Id) + .LeftJoin() + .On((contentVersion, documentVersion) => + contentVersion.Id == documentVersion.Id) + .Where((propertyData, contentVersion, documentVersion) => + (contentVersion.Current == true || documentVersion.Published == true) + && propertyData.PropertyTypeId == propertyType.Id); + + List propertyDataDtos = await Database.FetchAsync(sql); + if (propertyDataDtos.Count < 1) + { + continue; + } + + var updateItems = new List(); + + // process all the propertyData + // if none of the processors modify the value, the propertyData is skipped from being saved. + foreach (PropertyDataDto propertyDataDto in propertyDataDtos) + { + if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor, out UpdateItem? updateItem) is false) + { + continue; + } + + updateItems.Add(updateItem!); + } + + updateItemsByPropertyType[propertyType] = updateItems; + } + + return updateItemsByPropertyType; + } + + private async Task SavePropertyTypes(IDictionary> propertyTypes) + { + foreach (IPropertyType propertyType in propertyTypes.Keys) + { + // The dataType and valueEditor should be constructed as we have done this before, but we hate null values. + IDataType dataType = await _dataTypeService.GetAsync(propertyType.DataTypeKey) + ?? throw new InvalidOperationException("The data type could not be fetched."); + IDataValueEditor updatedValueEditor = dataType.Editor?.GetValueEditor() + ?? throw new InvalidOperationException( + "The data type value editor could not be obtained."); + + // batch by datatype + var propertyDataDtos = propertyTypes[propertyType].Select(item => item.PropertyDataDto).ToList(); + + var updateBatch = propertyDataDtos.Select(propertyDataDto => + UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))).ToList(); + + var updatesToSkip = new ConcurrentBag>(); + + var progress = 0; + + void HandleUpdateBatch(UpdateBatch update) + { + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + + progress++; + if (progress % 100 == 0) + { + _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, updateBatch.Count); + } + + PropertyDataDto propertyDataDto = update.Poco; + + if (FinalizeUpdateItem(propertyTypes[propertyType].First(item => Equals(item.PropertyDataDto, update.Poco)), updatedValueEditor) is false) + { + updatesToSkip.Add(update); + } + } + + if (DatabaseType == DatabaseType.SQLite) + { + // SQLite locks up if we run the migration in parallel, so... let's not. + foreach (UpdateBatch update in updateBatch) + { + HandleUpdateBatch(update); + } + } + else + { + Parallel.ForEachAsync(updateBatch, async (update, token) => + { + //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + HandleUpdateBatch(update); + }, + token); + } + + await task; + }).GetAwaiter().GetResult(); + } + + updateBatch.RemoveAll(updatesToSkip.Contains); + + if (updateBatch.Any() is false) + { + _logger.LogDebug(" - no properties to convert, continuing"); + continue; + } + + _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatch.Count); + var result = Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 }); + if (result != updateBatch.Count) + { + throw new InvalidOperationException( + $"The database batch update was supposed to update {updateBatch.Count} property DTO entries, but it updated {result} entries."); + } + + _logger.LogDebug( + "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", + propertyType.Name, + propertyType.Id, + propertyType.Alias, + propertyType.PropertyEditorAlias, + result); + } + + return true; + } + + private bool ProcessPropertyDataDto( + PropertyDataDto propertyDataDto, + IPropertyType propertyType, + IDictionary languagesById, + IDataValueEditor valueEditor, + out UpdateItem? updateItem) + { + // NOTE: some old property data DTOs can have variance defined, even if the property type no longer varies + var culture = propertyType.VariesByCulture() + && propertyDataDto.LanguageId.HasValue + && languagesById.TryGetValue(propertyDataDto.LanguageId.Value, out ILanguage? language) + ? language.IsoCode + : null; + + if (culture is null && propertyType.VariesByCulture()) + { + // if we end up here, the property DTO is bound to a language that no longer exists. this is an error scenario, + // and we can't really handle it in any other way than logging; in all likelihood this is an old property version, + // and it won't cause any runtime issues + _logger.LogWarning( + " - property data with id: {propertyDataId} references a language that does not exist - language id: {languageId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + propertyDataDto.LanguageId, + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updateItem = null; + return false; + } + + // create a fake property to be able to get a typed value and run it trough the processors. + var segment = propertyType.VariesBySegment() ? propertyDataDto.Segment : null; + var property = new Property(propertyType); + property.SetValue(propertyDataDto.Value, culture, segment); + var toEditorValue = valueEditor.ToEditor(property, culture, segment); + + if (TryTransformValue(toEditorValue, property, out var updatedValue) is false) + { + _logger.LogDebug( + " - skipping as no processor modified the data for property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updateItem = null; + return false; + } + + updateItem = new UpdateItem(propertyDataDto, propertyType, updatedValue); + return true; + } + + /// + /// Takes the updated value that was instanced from the db value by the old ValueEditors + /// And runs it through the updated ValueEditors and sets it on the PropertyDataDto + /// + private bool FinalizeUpdateItem(UpdateItem updateItem, IDataValueEditor updatedValueEditor) + { + var editorValue = _jsonSerializer.Serialize(updateItem.UpdatedValue); + var dbValue = updateItem.UpdatedValue is SingleBlockValue + ? _dummySingleBlockValueEditor.FromEditor(new ContentPropertyData(editorValue, null), null) + : updatedValueEditor.FromEditor(new ContentPropertyData(editorValue, null), null); + if (dbValue is not string stringValue || stringValue.DetectIsJson() is false) + { + _logger.LogWarning( + " - value editor did not yield a valid JSON string as FromEditor value property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + updateItem.PropertyDataDto.Id, + updateItem.PropertyType.Name, + updateItem.PropertyType.Id, + updateItem.PropertyType.Alias); + return false; + } + + updateItem.PropertyDataDto.TextValue = stringValue; + return true; + } + + /// + /// If the value is a BlockListValue, and its datatype is configured as single + /// We also need to convert the outer BlockListValue to a SingleBlockValue + /// Either way, we need to run the value through the processors to possibly update nested values + /// + private bool TryTransformValue(object? toEditorValue, Property property, out object? value) + { + bool hasChanged = _singleBlockListProcessor.ProcessToEditorValue(toEditorValue); + + if (toEditorValue is BlockListValue blockListValue + && _blockListConfigurationCache.IsPropertyEditorBlockListConfiguredAsSingle(property.PropertyType.DataTypeKey)) + { + value = _singleBlockListProcessor.ConvertBlockListToSingleBlock(blockListValue); + return true; + } + + value = toEditorValue; + return hasChanged; + } + + private class UpdateItem + { + public UpdateItem(PropertyDataDto propertyDataDto, IPropertyType propertyType, object? updatedValue) + { + PropertyDataDto = propertyDataDto; + PropertyType = propertyType; + UpdatedValue = updatedValue; + } + + public object? UpdatedValue { get; set; } + + public PropertyDataDto PropertyDataDto { get; set; } + + public IPropertyType PropertyType { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs new file mode 100644 index 000000000000..7ad85505d922 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs @@ -0,0 +1,23 @@ +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public interface ITypedSingleBlockListProcessor +{ + /// + /// The type of the propertyEditor expects to receive as a value to process + /// + public Type PropertyEditorValueType { get; } + + /// + /// The property (data)editor aliases that this processor supports, as defined on their DataEditor attributes + /// + public IEnumerable PropertyEditorAliases { get; } + + /// + /// object?: the editorValue being processed + /// Func: the function that will be called when nested content is detected + /// + public Func, Func, bool> Process { get; } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs new file mode 100644 index 000000000000..367cb3af3d0b --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class MigrateSingleBlockListComposer : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs new file mode 100644 index 000000000000..ce88ef01e8b1 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs @@ -0,0 +1,41 @@ +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal abstract class SingleBlockBlockProcessorBase +{ + private readonly SingleBlockListConfigurationCache _blockListConfigurationCache; + + public SingleBlockBlockProcessorBase( + SingleBlockListConfigurationCache blockListConfigurationCache) + { + _blockListConfigurationCache = blockListConfigurationCache; + } + + protected bool ProcessBlockItemDataValues( + BlockItemData blockItemData, + Func processNested, + Func processOuterValue) + { + var hasChanged = false; + + foreach (BlockPropertyValue blockPropertyValue in blockItemData.Values) + { + if (processNested.Invoke(blockPropertyValue.Value)) + { + hasChanged = true; + } + + if (_blockListConfigurationCache.IsPropertyEditorBlockListConfiguredAsSingle( + blockPropertyValue.PropertyType!.DataTypeKey) + && blockPropertyValue.Value is BlockListValue blockListValue) + { + blockPropertyValue.Value = processOuterValue.Invoke(blockListValue); + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs new file mode 100644 index 000000000000..f84077a47ef6 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs @@ -0,0 +1,50 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListBlockGridProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListBlockGridProcessor(SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(BlockGridValue); + + public IEnumerable PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockGrid]; + + public Func,Func, bool> Process => ProcessBlocks; + + private bool ProcessBlocks( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not BlockGridValue blockValue) + { + return false; + } + + bool hasChanged = false; + + foreach (BlockItemData contentData in blockValue.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in blockValue.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs new file mode 100644 index 000000000000..71c7f65feb75 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs @@ -0,0 +1,52 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListBlockListProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListBlockListProcessor( + SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(BlockListValue); + + public IEnumerable PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockList]; + + public Func, Func, bool> Process => ProcessBlocks; + + private bool ProcessBlocks( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not BlockListValue blockValue) + { + return false; + } + + bool hasChanged = false; + + // there might be another list inside the single list so more recursion, yeeey! + foreach (BlockItemData contentData in blockValue.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in blockValue.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs new file mode 100644 index 000000000000..7e342b3de7a9 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs @@ -0,0 +1,52 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +/// +/// Used by the SingleBlockList Migration and its processors to avoid having to fetch (and thus lock) +/// data from the db multiple times during the migration. +/// +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public class SingleBlockListConfigurationCache +{ + private readonly IDataTypeService _dataTypeService; + private readonly List _singleBlockListDataTypes = new(); + + public SingleBlockListConfigurationCache(IDataTypeService dataTypeService) + { + _dataTypeService = dataTypeService; + } + + /// + /// Populates a cache that holds all the property editor aliases that have a BlockList configuration with UseSingleBlockMode set to true. + /// + /// The number of blocklists with UseSingleBlockMode set to true. + public async Task Populate() + { + IEnumerable blockListDataTypes = + await _dataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.BlockList); + + foreach (IDataType dataType in blockListDataTypes) + { + if (dataType.ConfigurationObject is BlockListConfiguration + { + UseSingleBlockMode: true, ValidationLimit.Max: 1 + }) + { + _singleBlockListDataTypes.Add(dataType); + } + } + + return _singleBlockListDataTypes.Count; + } + + // returns whether the passed in key belongs to a blocklist with UseSingleBlockMode set to true + public bool IsPropertyEditorBlockListConfiguredAsSingle(Guid key) => + _singleBlockListDataTypes.Any(dt => dt.Key == key); + + // The list of all blocklist data types that have UseSingleBlockMode set to true + public IEnumerable CachedDataTypes => _singleBlockListDataTypes.AsReadOnly(); +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs new file mode 100644 index 000000000000..ae13717f8f21 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs @@ -0,0 +1,51 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public class SingleBlockListProcessor +{ + private readonly IEnumerable _processors; + + public SingleBlockListProcessor(IEnumerable processors) => _processors = processors; + + public IEnumerable GetSupportedPropertyEditorAliases() => + _processors.SelectMany(p => p.PropertyEditorAliases); + /// + /// The entry point of the recursive conversion + /// Find the first processor that can handle the value and call it's Process method + /// + /// Whether the value was changed + public bool ProcessToEditorValue(object? editorValue) + { + ITypedSingleBlockListProcessor? processor = + _processors.FirstOrDefault(p => p.PropertyEditorValueType == editorValue?.GetType()); + + return processor is not null && processor.Process.Invoke(editorValue, ProcessToEditorValue, ConvertBlockListToSingleBlock); + } + + /// + /// Updates and returns the passed in BlockListValue to a SingleBlockValue + /// Should only be called by a core processor once a BlockListValue has been found that is configured in single block mode. + /// + public BlockValue ConvertBlockListToSingleBlock(BlockListValue blockListValue) + { + IBlockLayoutItem blockListLayoutItem = blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].First(); + + var singleBlockLayoutItem = new SingleBlockLayoutItem + { + ContentKey = blockListLayoutItem.ContentKey, + SettingsKey = blockListLayoutItem.SettingsKey, + }; + + var singleBlockValue = new SingleBlockValue(singleBlockLayoutItem) + { + ContentData = blockListValue.ContentData, + SettingsData = blockListValue.SettingsData, + Expose = blockListValue.Expose, + }; + + return singleBlockValue; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs new file mode 100644 index 000000000000..88de3b839b36 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // available in v17, activated in v18 migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListRteProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListRteProcessor(SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(RichTextEditorValue); + + public IEnumerable PropertyEditorAliases => + [ + "Umbraco.TinyMCE", Constants.PropertyEditors.Aliases.RichText + ]; + + public Func,Func, bool> Process => ProcessRichText; + + public bool ProcessRichText( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not RichTextEditorValue richTextValue) + { + return false; + } + + var hasChanged = false; + + if (richTextValue.Blocks is null) + { + return hasChanged; + } + + foreach (BlockItemData contentData in richTextValue.Blocks.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in richTextValue.Blocks.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs b/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs index 88d1326f44dd..c66e121cf736 100644 --- a/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs +++ b/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs @@ -22,7 +22,7 @@ public class Query : IQuery /// public virtual IQuery Where(Expression>? predicate) { - if (predicate == null) + if (predicate is null) { return this; } @@ -38,7 +38,7 @@ public virtual IQuery Where(Expression>? predicate) /// public virtual IQuery WhereIn(Expression>? fieldSelector, IEnumerable? values) { - if (fieldSelector == null) + if (fieldSelector is null) { return this; } @@ -49,12 +49,28 @@ public virtual IQuery WhereIn(Expression>? fieldSelector, IEn return this; } + /// + /// Adds a where-not-in clause to the query. + /// + public virtual IQuery WhereNotIn(Expression>? fieldSelector, IEnumerable? values) + { + if (fieldSelector is null) + { + return this; + } + + var expressionHelper = new ModelToSqlExpressionVisitor(_sqlContext.SqlSyntax, _sqlContext.Mappers); + var whereExpression = expressionHelper.Visit(fieldSelector); + _wheres.Add(new Tuple(whereExpression + " NOT IN (@values)", new object[] { new { values } })); + return this; + } + /// /// Adds a set of OR-ed where clauses to the query. /// public virtual IQuery WhereAny(IEnumerable>>? predicates) { - if (predicates == null) + if (predicates is null) { return this; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PropertyTypeUsageRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PropertyTypeUsageRepository.cs index ab9a03bcefa1..64917f9af149 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PropertyTypeUsageRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PropertyTypeUsageRepository.cs @@ -1,4 +1,3 @@ - using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Persistence.Repositories; @@ -8,28 +7,26 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +/// internal sealed class PropertyTypeUsageRepository : IPropertyTypeUsageRepository { - private static readonly Guid?[] NodeObjectTypes = new Guid?[] - { + private static readonly List _nodeObjectTypes = + [ Constants.ObjectTypes.DocumentType, Constants.ObjectTypes.MediaType, Constants.ObjectTypes.MemberType, - }; + ]; private readonly IScopeAccessor _scopeAccessor; - public PropertyTypeUsageRepository(IScopeAccessor scopeAccessor) - { - _scopeAccessor = scopeAccessor; - } + /// + /// Initializes a new instance of the class. + /// + public PropertyTypeUsageRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + /// public Task HasSavedPropertyValuesAsync(Guid contentTypeKey, string propertyAlias) { - IUmbracoDatabase? database = _scopeAccessor.AmbientScope?.Database; - - if (database is null) - { - throw new InvalidOperationException("A scope is required to query the database"); - } + IUmbracoDatabase? database = _scopeAccessor.AmbientScope?.Database + ?? throw new InvalidOperationException("A scope is required to query the database"); Sql selectQuery = database.SqlContext.Sql() .SelectAll() @@ -47,26 +44,21 @@ public Task HasSavedPropertyValuesAsync(Guid contentTypeKey, string proper return Task.FromResult(database.ExecuteScalar(hasValuesQuery)); } + /// public Task ContentTypeExistAsync(Guid contentTypeKey) { - IUmbracoDatabase? database = _scopeAccessor.AmbientScope?.Database; - - if (database is null) - { - throw new InvalidOperationException("A scope is required to query the database"); - } + IUmbracoDatabase? database = _scopeAccessor.AmbientScope?.Database + ?? throw new InvalidOperationException("A scope is required to query the database"); Sql selectQuery = database.SqlContext.Sql() .SelectAll() .From("n") .Where(n => n.UniqueId == contentTypeKey, "n") - .Where(n => NodeObjectTypes.Contains(n.NodeObjectType), "n"); + .WhereIn(n => n.NodeObjectType, _nodeObjectTypes, "n"); Sql hasValuesQuery = database.SqlContext.Sql() .SelectAnyIfExists(selectQuery); return Task.FromResult(database.ExecuteScalar(hasValuesQuery)); } - - } diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 9cefd32b5bef..82bbf26f810e 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -100,6 +100,10 @@ protected SqlSyntaxProviderBase() public string TimeColumnDefinition { get; protected set; } = "DATETIME"; + public string DateOnlyColumnDefinition { get; protected set; } = "DATE"; + + public string TimeOnlyColumnDefinition { get; protected set; } = "TIME"; + protected IList> ClauseOrder { get; } protected DbTypes DbTypeMap => _dbTypes.Value; @@ -531,6 +535,10 @@ private DbTypes InitColumnTypeMap() dbTypeMap.Set(DbType.Time, TimeColumnDefinition); dbTypeMap.Set(DbType.DateTimeOffset, DateTimeOffsetColumnDefinition); dbTypeMap.Set(DbType.DateTimeOffset, DateTimeOffsetColumnDefinition); + dbTypeMap.Set(DbType.Date, DateOnlyColumnDefinition); + dbTypeMap.Set(DbType.Date, DateOnlyColumnDefinition); + dbTypeMap.Set(DbType.Time, TimeOnlyColumnDefinition); + dbTypeMap.Set(DbType.Time, TimeOnlyColumnDefinition); dbTypeMap.Set(DbType.Byte, IntColumnDefinition); dbTypeMap.Set(DbType.Byte, IntColumnDefinition); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs index fe419a6566c2..f1da6618487f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -10,8 +10,11 @@ namespace Umbraco.Cms.Core.PropertyEditors; -internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor +internal sealed partial class ColorPickerConfigurationEditor : ConfigurationEditor { + /// + /// Initializes a new instance of the class. + /// public ColorPickerConfigurationEditor(IIOHelper ioHelper, IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) : base(ioHelper) { @@ -19,13 +22,17 @@ public ColorPickerConfigurationEditor(IIOHelper ioHelper, IConfigurationEditorJs items.Validators.Add(new ColorListValidator(configurationEditorJsonSerializer)); } - internal sealed class ColorListValidator : IValueValidator + internal sealed partial class ColorListValidator : IValueValidator { private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; + /// + /// Initializes a new instance of the class. + /// public ColorListValidator(IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) => _configurationEditorJsonSerializer = configurationEditorJsonSerializer; + /// public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) { var stringValue = value?.ToString(); @@ -46,17 +53,53 @@ public IEnumerable Validate(object? value, string? valueType, if (items is null) { - yield return new ValidationResult($"The configuration value {stringValue} is not a valid color picker configuration", new[] { "items" }); + yield return new ValidationResult($"The configuration value {stringValue} is not a valid color picker configuration", ["items"]); yield break; } + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var duplicates = new List(); foreach (ColorPickerConfiguration.ColorPickerItem item in items) { - if (Regex.IsMatch(item.Value, "^([0-9a-f]{3}|[0-9a-f]{6})$", RegexOptions.IgnoreCase) == false) + if (ColorPattern().IsMatch(item.Value) == false) { - yield return new ValidationResult($"The value {item.Value} is not a valid hex color", new[] { "items" }); + yield return new ValidationResult($"The value {item.Value} is not a valid hex color", ["items"]); + continue; + } + + var normalized = Normalize(item.Value); + if (seen.Add(normalized) is false) + { + duplicates.Add(normalized); } } + + if (duplicates.Count > 0) + { + yield return new ValidationResult( + $"Duplicate color values are not allowed: {string.Join(", ", duplicates)}", + ["items"]); + } } + + private static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalizedValue = value.Trim().ToLowerInvariant(); + + if (normalizedValue.Length == 3) + { + normalizedValue = $"{normalizedValue[0]}{normalizedValue[0]}{normalizedValue[1]}{normalizedValue[1]}{normalizedValue[2]}{normalizedValue[2]}"; + } + + return normalizedValue; + } + + [GeneratedRegex("^([0-9a-f]{3}|[0-9a-f]{6})$", RegexOptions.IgnoreCase, "en-GB")] + private static partial Regex ColorPattern(); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index 9dcd48fc30a4..a0cc832bb943 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -29,6 +30,8 @@ public class ImageCropperPropertyEditor : DataEditor, INotificationHandler, INotificationHandler, INotificationHandler, + INotificationHandler, + INotificationHandler, INotificationHandler { private readonly UploadAutoFillProperties _autoFillProperties; @@ -63,6 +66,7 @@ public ImageCropperPropertyEditor( _contentSettings = contentSettings.CurrentValue; contentSettings.OnChange(x => _contentSettings = x); + SupportsReadOnly = true; } @@ -122,10 +126,13 @@ public void Handle(ContentCopiedNotification notification) } } + /// public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); + /// public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); + /// public void Handle(MediaSavingNotification notification) { foreach (IMedia entity in notification.SavedEntities) @@ -134,6 +141,34 @@ public void Handle(MediaSavingNotification notification) } } + /// + public void Handle(MediaMovedToRecycleBinNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + return; + } + + SuffixContainedFiles( + notification.MoveInfoCollection + .Select(x => x.Entity)); + } + + /// + public void Handle(MediaMovedNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + return; + } + + RemoveSuffixFromContainedFiles( + notification.MoveInfoCollection + .Where(x => x.OriginalPath.StartsWith($"{Constants.System.RootString},{Constants.System.RecycleBinMediaString}")) + .Select(x => x.Entity)); + } + + /// public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); /// @@ -236,12 +271,36 @@ private IEnumerable GetFilePathsFromPropertyValues(IProperty prop) return relative ? _mediaFileManager.FileSystem.GetRelativePath(source) : source; } + /// + /// Deletes all file upload property files contained within a collection of content entities. + /// + /// Delete media entities. private void DeleteContainedFiles(IEnumerable deletedEntities) { IEnumerable filePathsToDelete = ContainedFilePaths(deletedEntities); _mediaFileManager.DeleteMediaFiles(filePathsToDelete); } + /// + /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin. + /// + /// Media entities that have been moved to the recycle bin. + private void SuffixContainedFiles(IEnumerable trashedMedia) + { + IEnumerable filePathsToRename = ContainedFilePaths(trashedMedia); + RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, _mediaFileManager); + } + + /// + /// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin. + /// + /// Media entities that have been restored from the recycle bin. + private void RemoveSuffixFromContainedFiles(IEnumerable restoredMedia) + { + IEnumerable filePathsToRename = ContainedFilePaths(restoredMedia); + RecycleBinMediaProtectionHelper.RemoveSuffixFromContainedFiles(filePathsToRename, _mediaFileManager); + } + /// /// Auto-fill properties (or clear). /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs index 4203209b580a..1c36bea04df8 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -14,16 +16,20 @@ namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; /// -/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files. +/// Provides base class for notification handler that processes file uploads when a content entity is deleted or media +/// operations are carried out, processing the associated files. /// internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler, INotificationHandler, INotificationHandler, + INotificationHandler, + INotificationHandler, INotificationHandler { private readonly BlockEditorValues _blockListEditorValues; private readonly BlockEditorValues _blockGridEditorValues; + private ContentSettings _contentSettings; /// /// Initializes a new instance of the class. @@ -32,11 +38,15 @@ public FileUploadContentDeletedNotificationHandler( IJsonSerializer jsonSerializer, MediaFileManager mediaFileManager, IBlockEditorElementTypeCache elementTypeCache, - ILogger logger) + ILogger logger, + IOptionsMonitor contentSettngs) : base(jsonSerializer, mediaFileManager, elementTypeCache) { _blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger); _blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger); + + _contentSettings = contentSettngs.CurrentValue; + contentSettngs.OnChange(x => _contentSettings = x); } /// @@ -48,19 +58,66 @@ public FileUploadContentDeletedNotificationHandler( /// public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); + /// + public void Handle(MediaMovedToRecycleBinNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + return; + } + + SuffixContainedFiles( + notification.MoveInfoCollection + .Select(x => x.Entity)); + } + + /// + public void Handle(MediaMovedNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + return; + } + + RemoveSuffixFromContainedFiles( + notification.MoveInfoCollection + .Where(x => x.OriginalPath.StartsWith($"{Constants.System.RootString},{Constants.System.RecycleBinMediaString}")) + .Select(x => x.Entity)); + } + /// public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); /// /// Deletes all file upload property files contained within a collection of content entities. /// - /// + /// Delete media entities. private void DeleteContainedFiles(IEnumerable deletedEntities) { IReadOnlyList filePathsToDelete = ContainedFilePaths(deletedEntities); MediaFileManager.DeleteMediaFiles(filePathsToDelete); } + /// + /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin. + /// + /// Media entities that have been moved to the recycle bin. + private void SuffixContainedFiles(IEnumerable trashedMedia) + { + IEnumerable filePathsToRename = ContainedFilePaths(trashedMedia); + RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, MediaFileManager); + } + + /// + /// Renames all file upload property files contained within a collection of media entities that have been restored from the recycle bin. + /// + /// Media entities that have been restored from the recycle bin. + private void RemoveSuffixFromContainedFiles(IEnumerable restoredMedia) + { + IEnumerable filePathsToRename = ContainedFilePaths(restoredMedia); + MediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix); + } + /// /// Gets the paths to all file upload property files contained within a collection of content entities. /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs new file mode 100644 index 000000000000..ca540b9c6153 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Provides helper methods for multiple notification handlers dealing with protection of media files for media in the recycle bin. +/// +internal static class RecycleBinMediaProtectionHelper +{ + /// + /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin. + /// + /// Media file paths. + /// The media file manager. + public static void SuffixContainedFiles(IEnumerable filePaths, MediaFileManager mediaFileManager) + => mediaFileManager.SuffixMediaFiles(filePaths, Constants.Conventions.Media.TrashedMediaSuffix); + + /// + /// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin. + /// + /// Media file paths. + /// The media file manager. + public static void RemoveSuffixFromContainedFiles(IEnumerable filePaths, MediaFileManager mediaFileManager) + { + IEnumerable filePathsToRename = filePaths + .Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x))); + mediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix); + } +} diff --git a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs index e64f22b8869a..72c9b16d77ad 100644 --- a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs +++ b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs @@ -65,7 +65,7 @@ public async Task SendForgotPassword(UserForgotPasswordMessage messageModel) var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); - await _emailSender.SendAsync(message, Constants.Web.EmailTypes.PasswordReset, true); + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.PasswordReset, true, _securitySettings.PasswordResetEmailExpiry); } public bool CanSend() => _securitySettings.AllowPasswordReset && _emailSender.CanSendRequiredEmail(); diff --git a/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs index b6ef7a7447a8..6222e01ef243 100644 --- a/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs +++ b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs @@ -1,9 +1,11 @@ -using System.Globalization; +using System.Globalization; using System.Net; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MimeKit; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mail; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Email; @@ -18,15 +20,31 @@ public class EmailUserInviteSender : IUserInviteSender private readonly IEmailSender _emailSender; private readonly ILocalizedTextService _localizedTextService; private readonly GlobalSettings _globalSettings; + private readonly SecuritySettings _securitySettings; + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 18.")] public EmailUserInviteSender( IEmailSender emailSender, ILocalizedTextService localizedTextService, IOptions globalSettings) + : this( + emailSender, + localizedTextService, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public EmailUserInviteSender( + IEmailSender emailSender, + ILocalizedTextService localizedTextService, + IOptions globalSettings, + IOptions securitySettings) { _emailSender = emailSender; _localizedTextService = localizedTextService; _globalSettings = globalSettings.Value; + _securitySettings = securitySettings.Value; } public async Task InviteUser(UserInvitationMessage invite) @@ -67,7 +85,7 @@ public async Task InviteUser(UserInvitationMessage invite) var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); - await _emailSender.SendAsync(message, Constants.Web.EmailTypes.UserInvite, true); + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.UserInvite, true, _securitySettings.UserInviteEmailExpiry); } public bool CanSendInvites() => _emailSender.CanSendRequiredEmail(); diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index 1263f52e5e4c..aaab2497ffb0 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -42,12 +42,6 @@ public bool TryRebuild(IIndex index, string indexName) /// public async Task TryRebuildAsync(IIndex index, string indexName) { - // Remove it in case there's a handler there already - index.IndexOperationComplete -= Indexer_IndexOperationComplete; - - // Now add a single handler - index.IndexOperationComplete += Indexer_IndexOperationComplete; - try { Attempt attempt = await _indexRebuilder.RebuildIndexAsync(indexName); @@ -55,8 +49,6 @@ public async Task TryRebuildAsync(IIndex index, string indexName) } catch (Exception exception) { - // Ensure it's not listening - index.IndexOperationComplete -= Indexer_IndexOperationComplete; _logger.LogError(exception, "An error occurred rebuilding index"); return false; } @@ -70,19 +62,4 @@ public bool IsRebuilding(string indexName) /// public Task IsRebuildingAsync(string indexName) => _indexRebuilder.IsRebuildingAsync(indexName); - - private void Indexer_IndexOperationComplete(object? sender, EventArgs e) - { - var indexer = (IIndex?)sender; - - _logger.LogDebug("Logging operation completed for index {IndexName}", indexer?.Name); - - if (indexer is not null) - { - //ensure it's not listening anymore - indexer.IndexOperationComplete -= Indexer_IndexOperationComplete; - } - - _logger.LogInformation("Rebuilding index '{IndexerName}' done.", indexer?.Name); - } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index b3d31d323b0f..86bea154c610 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -270,6 +270,7 @@ public static IUmbracoBuilder AddWebComponents(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs index ada8f64fdfa4..76ef786fbf19 100644 --- a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs @@ -104,6 +104,7 @@ public static IApplicationBuilder UseUmbracoRouting(this IApplicationBuilder app app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); } return app; diff --git a/src/Umbraco.Web.Common/Extensions/StringExtensions.cs b/src/Umbraco.Web.Common/Extensions/StringExtensions.cs new file mode 100644 index 000000000000..29cff82f62a8 --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/StringExtensions.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Web.Common.Extensions; + +internal static class StringExtensions +{ + /// + /// Provides a robust way to check if a path starts with another path, normalizing multiple slashes. + /// + internal static bool StartsWithNormalizedPath(this string path, string other, StringComparison comparisonType = StringComparison.Ordinal) + { + // First check without normalizing. + if (path.StartsWith(other, comparisonType)) + { + return true; + } + + // Normalize paths by splitting them into segments, removing multiple slashes. + var otherSegments = other.Split(Core.Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); + var pathSegments = path.Split(Core.Constants.CharArrays.ForwardSlash, otherSegments.Length + 1, StringSplitOptions.RemoveEmptyEntries); + + // The path should have at least as many segments as the other path + if (otherSegments.Length > pathSegments.Length) + { + return false; + } + + // Check each segment. + for (int i = otherSegments.Length - 1; i >= 0; i--) + { + if (!string.Equals(otherSegments[i], pathSegments[i], comparisonType)) + { + return false; + } + } + + // All segments match. + return true; + } +} diff --git a/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs b/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs new file mode 100644 index 000000000000..2df70c2de5d6 --- /dev/null +++ b/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Web.Common.Extensions; + +namespace Umbraco.Cms.Web.Common.Middleware; + +/// +/// Ensures that requests to the media in the recycle bin are authorized and only authenticated back-office users +/// with permissions for the media have access. +/// +public class ProtectRecycleBinMediaMiddleware : IMiddleware +{ + private ContentSettings _contentSettings; + + /// + /// Initializes a new instance of the class. + /// + public ProtectRecycleBinMediaMiddleware( + IOptionsMonitor contentSettings) + { + _contentSettings = contentSettings.CurrentValue; + contentSettings.OnChange(x => _contentSettings = x); + } + + /// + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + await next(context); + return; + } + + string? requestPath = context.Request.Path.Value; + + if (string.IsNullOrEmpty(requestPath) || + requestPath.StartsWithNormalizedPath($"/media/", StringComparison.OrdinalIgnoreCase) is false || + requestPath.Contains(Core.Constants.Conventions.Media.TrashedMediaSuffix + ".") is false) + { + await next(context); + return; + } + + AuthenticateResult authenticateResult = await context.AuthenticateAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType); + if (authenticateResult.Succeeded is false) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + Claim? mediaSectionClaim = authenticateResult.Principal.Claims + .FirstOrDefault(x => x.Type == Core.Constants.Security.AllowedApplicationsClaimType && x.Value == Core.Constants.Applications.Media); + + if (mediaSectionClaim is null) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(context); + } +} diff --git a/src/Umbraco.Web.UI.Client/.github/README.md b/src/Umbraco.Web.UI.Client/.github/README.md index cf76fd68ef7c..d677ad9e0dfd 100644 --- a/src/Umbraco.Web.UI.Client/.github/README.md +++ b/src/Umbraco.Web.UI.Client/.github/README.md @@ -26,6 +26,7 @@ If you have an existing Vite server running, you can run the task **Backoffice A ### Run a Front-end server against a local Umbraco instance #### 1. Configure Umbraco instance + Enable the front-end server communicating with the Backend server(Umbraco instance) you need need to correct the `appsettings.json` of your project. For code contributions use the backend project of `/src/Umbraco.Web.UI`. @@ -38,7 +39,11 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json` "BackOfficeHost": "http://localhost:5173", "AuthorizeCallbackPathName": "/oauth_complete", "AuthorizeCallbackLogoutPathName": "/logout", - "AuthorizeCallbackErrorPathName": "/error", + "AuthorizeCallbackErrorPathName": "/error",, + "BackOfficeTokenCookie": { + "Enabled": true, + "SameSite": "None" + } }, }, } @@ -46,10 +51,15 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json` This will override the backoffice host URL, enabling the Client to run from a different origin. +> [!NOTE] +> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately. + #### 2. Start Umbraco + Then start the backend server by running the command: `dotnet run` in the `/src/Umbraco.Web.UI` folder. #### 3. Start Frontend server + Now start the frontend server by running the command: `npm run dev:server` in the `/src/Umbraco.Web.UI.Client` folder. Finally open `http://localhost:5173` in your browser. diff --git a/src/Umbraco.Web.UI.Client/README.md b/src/Umbraco.Web.UI.Client/README.md index 1404fba49ca8..0bc565c8edab 100644 --- a/src/Umbraco.Web.UI.Client/README.md +++ b/src/Umbraco.Web.UI.Client/README.md @@ -2,6 +2,14 @@ This package contains the types for the Umbraco Backoffice. +## Preview + +A live preview of the latest backoffice build from the main branch is available at: + +**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)** + +This preview is automatically deployed via GitHub Actions whenever changes are pushed to main or version branches. + ## Installation ```bash diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index f570a7800b26..0aea8fee90dd 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@umbraco-cms/backoffice", - "version": "17.0.0-rc2", + "version": "17.1.0-rc", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@umbraco-cms/backoffice", - "version": "17.0.0-rc2", + "version": "17.1.0-rc", "license": "MIT", "workspaces": [ "./src/packages/*", @@ -3638,6 +3638,10 @@ "resolved": "src/packages/performance-profiling", "link": true }, + "node_modules/@umbraco-backoffice/preview": { + "resolved": "src/packages/preview", + "link": true + }, "node_modules/@umbraco-backoffice/property-editors": { "resolved": "src/packages/property-editors", "link": true @@ -17084,6 +17088,9 @@ "src/packages/performance-profiling": { "name": "@umbraco-backoffice/performance-profiling" }, + "src/packages/preview": { + "name": "@umbraco-backoffice/preview" + }, "src/packages/property-editors": { "name": "@umbraco-backoffice/property-editors" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 33376593d32a..66b75fa2bc2f 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -1,7 +1,7 @@ { "name": "@umbraco-cms/backoffice", "license": "MIT", - "version": "17.0.0-rc3", + "version": "17.1.0-rc", "type": "module", "exports": { ".": null, @@ -85,6 +85,7 @@ "./picker-input": "./dist-cms/packages/core/picker-input/index.js", "./picker-data-source": "./dist-cms/packages/core/picker-data-source/index.js", "./picker": "./dist-cms/packages/core/picker/index.js", + "./preview": "./dist-cms/packages/preview/index.js", "./property-action": "./dist-cms/packages/core/property-action/index.js", "./property-editor-data-source": "./dist-cms/packages/core/property-editor-data-source/index.js", "./property-editor": "./dist-cms/packages/core/property-editor/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header.element.ts index 91cd28e05632..e0321b193cd4 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header.element.ts @@ -20,6 +20,7 @@ export class UmbBackofficeHeaderElement extends UmbLitElement { } #appHeader { + --uui-focus-outline-color: var(--uui-color-header-contrast-emphasis); background-color: var(--umb-header-background-color, var(--uui-color-header-surface)); display: flex; align-items: center; diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/index.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/index.ts new file mode 100644 index 000000000000..d90c7995f9bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/index.ts @@ -0,0 +1 @@ +export * from './preview.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts index 4897f21190dc..81046d2e5b9d 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts @@ -1,8 +1,14 @@ -import { manifests as previewApps } from './apps/manifests.js'; -import { UmbPreviewContext } from './preview.context.js'; import { css, customElement, html, nothing, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { + umbExtensionsRegistry, + UmbBackofficeEntryPointExtensionInitializer, +} from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbPreviewContext } from '@umbraco-cms/backoffice/preview'; +import { UmbServerExtensionRegistrator } from '@umbraco-cms/backoffice/extension-api'; +import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; + +const CORE_PACKAGES = [import('../../packages/preview/umbraco-package.js')]; /** * @element umb-preview @@ -11,66 +17,62 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; export class UmbPreviewElement extends UmbLitElement { #context = new UmbPreviewContext(this); + @state() + private _iframeReady?: boolean; + + @state() + private _previewUrl?: string; + constructor() { super(); - if (previewApps?.length) { - umbExtensionsRegistry.registerMany(previewApps); - } + new UmbBackofficeEntryPointExtensionInitializer(this, umbExtensionsRegistry); this.observe(this.#context.iframeReady, (iframeReady) => (this._iframeReady = iframeReady)); this.observe(this.#context.previewUrl, (previewUrl) => (this._previewUrl = previewUrl)); } - override connectedCallback() { - super.connectedCallback(); - this.addEventListener('visibilitychange', this.#onVisibilityChange); - window.addEventListener('beforeunload', () => this.#context.exitSession()); - this.#context.startSession(); - } + override async firstUpdated() { + await this.#extensionsAfterAuth(); - override disconnectedCallback() { - super.disconnectedCallback(); - this.removeEventListener('visibilitychange', this.#onVisibilityChange); - // NOTE: Unsure how we remove an anonymous function from 'beforeunload' event listener. - // The reason for the anonymous function is that if we used a named function, - // `this` would be the `window` and would not have context to the class instance. [LK] - //window.removeEventListener('beforeunload', () => this.#context.exitSession()); - this.#context.exitSession(); + // Extensions are loaded in parallel and don't need to block the preview frame + CORE_PACKAGES.forEach(async (packageImport) => { + const { extensions } = await packageImport; + umbExtensionsRegistry.registerMany(extensions); + }); } - @state() - private _iframeReady?: boolean; - - @state() - private _previewUrl?: string; + async #extensionsAfterAuth() { + const authContext = await this.getContext(UMB_AUTH_CONTEXT, { preventTimeout: true }); + if (!authContext) { + throw new Error('UmbPreviewElement requires the UMB_AUTH_CONTEXT to be set.'); + } + await this.observe(authContext.isAuthorized).asPromise(); + await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPrivateExtensions(); + } #onIFrameLoad(event: Event & { target: HTMLIFrameElement }) { this.#context.iframeLoaded(event.target); } - #onVisibilityChange() { - this.#context.checkSession(); - } - override render() { if (!this._previewUrl) return nothing; return html` ${when(!this._iframeReady, () => html`
`)} -
+
+ sandbox="allow-scripts allow-same-origin allow-forms">
`; } @@ -83,10 +85,7 @@ export class UmbPreviewElement extends UmbLitElement { align-items: center; position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; padding-bottom: 40px; } @@ -103,7 +102,9 @@ export class UmbPreviewElement extends UmbLitElement { bottom: 0; font-size: 6rem; - backdrop-filter: blur(5px); + backdrop-filter: blur(var(--uui-size-1, 3px)); + + z-index: 1; } #wrapper { @@ -118,7 +119,7 @@ export class UmbPreviewElement extends UmbLitElement { overflow: hidden; } - #wrapper.shadow { + #wrapper:not(.fullsize) { margin: 10px auto; background-color: white; border-radius: 3px; @@ -144,6 +145,8 @@ export class UmbPreviewElement extends UmbLitElement { left: 0; right: 0; + z-index: 1; + background-color: var(--uui-color-header-surface); height: 40px; @@ -157,7 +160,9 @@ export class UmbPreviewElement extends UmbLitElement { padding: 0 15px; } - #menu > uui-button-group { + #apps { + display: inline-flex; + align-items: stretch; height: 100%; } diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts index d0f96464b3cf..24fc31883c91 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts @@ -1009,7 +1009,18 @@ export default { document: 'Dokument', }, colors: { + black: 'Sort', blue: 'Blå', + brown: 'Brun', + cyan: 'Cyan', + green: 'Grøn', + lightBlue: 'Lyseblå', + pink: 'Lyserød', + red: 'Rød', + text: 'Sort', + yellow: 'Gul', + white: 'Hvid', + grey: 'Grå', }, shortcuts: { addGroup: 'Tilføj fane', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/de.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/de.ts index 9209a05fc98b..d8ee0250545d 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/de.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/de.ts @@ -18,6 +18,7 @@ export default { changeDataType: 'Datentyp ändern', copy: 'Kopieren', create: 'Neu', + createFor: (name: string) => (name ? `Neu erstellen für ${name}` : 'Neu'), export: 'Exportieren', createPackage: 'Neues Paket', createGroup: 'Neue Gruppe', @@ -63,6 +64,7 @@ export default { unlock: 'Freigeben', createblueprint: 'Inhaltsvorlage anlegen', resendInvite: 'Einladung erneut versenden', + viewActionsFor: (name: string) => (name ? `Aktionen anzeigen für ${name}` : 'Aktionen anzeigen'), }, actionCategories: { content: 'Inhalt', @@ -901,7 +903,18 @@ export default { newVersionAvailable: 'Neue Version verfügbar', }, colors: { + black: 'Schwarz', blue: 'Blau', + brown: 'Braun', + cyan: 'Cyan', + green: 'Grün', + lightBlue: 'Hellblau', + pink: 'Pink', + red: 'Rot', + text: 'Schwarz', + yellow: 'Gelb', + white: 'Weiß', + grey: 'Grau', }, shortcuts: { addTab: 'Tab hinzufügen', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index c674813e1ddd..74fbdbe0f6c2 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -35,6 +35,9 @@ export default { showLabelDescription: 'Displays colored field and a label for each color in the color picker, rather than just a colored field.', }, + colors: { + grey: 'Gray', + }, create: { folderDescription: 'Used to organize items and other folders. Keep items structured and easy to access.', }, diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 05fa44a2d0b2..839dfc9e7ec8 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -23,6 +23,7 @@ export default { copy: 'Duplicate', copyTo: 'Duplicate to', create: 'Create', + createFor: (name: string) => (name ? `Create item for ${name}` : 'Create'), createblueprint: 'Create Document Blueprint', createGroup: 'Create group', createPackage: 'Create Package', @@ -1016,9 +1017,21 @@ export default { if (new Date(date).getTime() < new Date(now).getTime()) return `${duration} ago`; return `in ${duration}`; }, + clipboard: 'Clipboard', }, colors: { + black: 'Black', blue: 'Blue', + brown: 'Brown', + cyan: 'Cyan', + green: 'Green', + lightBlue: 'Light Blue', + pink: 'Pink', + red: 'Red', + text: 'Black', + yellow: 'Yellow', + white: 'White', + grey: 'Grey', }, shortcuts: { addGroup: 'Add group', @@ -2233,6 +2246,7 @@ export default { rangeExceeds: 'The low value must not exceed the high value.', invalidExtensions: 'One or more of the extensions are invalid.', allowedExtensions: 'Allowed extensions are:', + aliasInvalidFormat: 'Special characters are not allowed in alias', disallowedExtensions: 'Disallowed extensions are:', }, healthcheck: { @@ -2496,6 +2510,7 @@ export default { labelForCopyToClipboard: 'Copy to clipboard', confirmDeleteHeadline: 'Delete from clipboard', confirmDeleteDescription: 'Are you sure you want to delete {0} from the clipboard?', + confirmClearDescription: 'Are you sure you want to clear the clipboard?', copySuccessHeadline: 'Copied to clipboard', }, propertyActions: { @@ -2555,16 +2570,21 @@ export default { }, welcomeDashboard: { umbracoForumHeadline: 'The Umbraco community forum', - umbracoForumDescription: 'The forum is the central hub for the Umbraco developer community. This is where developers, integrators, and contributors come together to ask questions, share knowledge, and collaborate on all things Umbraco.', + umbracoForumDescription: + 'The forum is the central hub for the Umbraco developer community. This is where developers, integrators, and contributors come together to ask questions, share knowledge, and collaborate on all things Umbraco.', umbracoForumButton: 'Visit the Umbraco community forum', umbracoCommunityHeadline: 'The Umbraco community site', - umbracoCommunityDescription: 'The gathering place for all things Umbraco. Whether you write, teach, test, give feedback, or want to connect with others, there’s a way for you to be part of the Friendly Umbraco community.', + umbracoCommunityDescription: + 'The gathering place for all things Umbraco. Whether you write, teach, test, give feedback, or want to connect with others, there’s a way for you to be part of the Friendly Umbraco community.', documentationHeadline: 'Documentation', - documentationDescription: 'Your guide to everything Umbraco. Learn how to get started, explore new features, and discover best practices through clear examples and explanations.', + documentationDescription: + 'Your guide to everything Umbraco. Learn how to get started, explore new features, and discover best practices through clear examples and explanations.', resourcesHeadline: 'Resources', - resourcesDescription: 'Explore Umbraco resources to learn, build, and grow your skills. Find blogs, tutorials, demos, documentation, and videos to help you make the most of Umbraco.', + resourcesDescription: + 'Explore Umbraco resources to learn, build, and grow your skills. Find blogs, tutorials, demos, documentation, and videos to help you make the most of Umbraco.', trainingHeadline: 'Training', - trainingDescription: 'Master Umbraco with official training. Get practical experience through instructor-led courses and earn certifications that help you grow your skills and career.', + trainingDescription: + 'Master Umbraco with official training. Get practical experience through instructor-led courses and earn certifications that help you grow your skills and career.', }, blockEditor: { headlineCreateBlock: 'Pick Element Type', @@ -2717,8 +2737,8 @@ export default { 'You can edit and delete Document Blueprints from the "Document Blueprints" tree in the Settings section. Expand the Document Type which the Document Blueprint is based on and click it to edit or delete it.', }, preview: { - endLabel: 'End', - endTitle: 'End preview mode', + endLabel: 'Exit', + endTitle: 'Exit preview mode', openWebsiteLabel: 'Preview website', openWebsiteTitle: 'Open website in preview mode', returnToPreviewHeadline: 'Preview website?', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/es.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/es.ts index 2e8ca5780b5c..6c525bd179dc 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/es.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/es.ts @@ -1393,6 +1393,7 @@ export default { invalidDate: 'Fecha no válida', invalidNumber: 'No es un número', invalidEmail: 'Email no válido', + aliasInvalidFormat: 'No se permiten caracteres especiales en el alias', }, healthcheck: { checkSuccessMessage: "El valor fue establecido en el valor recomendado: '%0%'.", diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/it.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/it.ts index 93d63be4111f..247a63e190ea 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/it.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/it.ts @@ -850,7 +850,18 @@ export default { avatar: 'Avatar per', }, colors: { + black: 'Nero', blue: 'Blu', + brown: 'Marrone', + cyan: 'Ciano', + green: 'Verde', + lightBlue: 'Azzurro', + pink: 'Rosa', + red: 'Rosso', + text: 'Nero', + yellow: 'Giallo', + white: 'Bianco', + grey: 'Grigio', }, shortcuts: { addGroup: 'Aggiungi gruppo', diff --git a/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts b/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts index fc32dbf8cba0..0ef5981a5b0a 100644 --- a/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts +++ b/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts @@ -35,6 +35,7 @@ export class FetchRequestor extends Requestor { const requestInit: RequestInit = {}; requestInit.method = settings.method; requestInit.mode = 'cors'; + requestInit.credentials = settings.credentials ?? 'include'; if (settings.data) { if (settings.method && settings.method.toUpperCase() === 'POST') { diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts new file mode 100644 index 000000000000..a61cb08b1ebe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts @@ -0,0 +1,133 @@ +import { UmbContextToken } from '../token/context-token.js'; +import type { UmbContextMinimal } from '../types.js'; +import { UmbContextProvider } from '../provide/context-provider.js'; +import { consumeContext } from './context-consume.decorator.js'; +import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { html, state } from '@umbraco-cms/backoffice/external/lit'; + +class UmbTestContextConsumerClass implements UmbContextMinimal { + public prop: string = 'value from provider'; + getHostElement() { + return undefined as any; + } +} + +const testToken = new UmbContextToken('my-test-context'); + +class MyTestElement extends UmbLitElement { + @consumeContext({ + context: testToken, + }) + @state() + contextValue?: UmbTestContextConsumerClass; + + override render() { + return html`
${this.contextValue?.prop ?? 'no context'}
`; + } +} + +customElements.define('my-consume-test-element', MyTestElement); + +describe('@consume decorator', () => { + let provider: UmbContextProvider; + let element: MyTestElement; + + beforeEach(async () => { + provider = new UmbContextProvider(document.body, testToken, new UmbTestContextConsumerClass()); + provider.hostConnected(); + + element = await fixture(``); + }); + + afterEach(() => { + provider.destroy(); + (provider as any) = undefined; + }); + + it('should receive a context value when provided on the host', () => { + expect(element.contextValue).to.equal(provider.providerInstance()); + expect(element.contextValue?.prop).to.equal('value from provider'); + }); + + it('should render the value from the context', async () => { + expect(element).shadowDom.to.equal('
value from provider
'); + }); + + it('should work when the decorator is used in a controller', async () => { + class MyController extends UmbControllerBase { + @consumeContext({ context: testToken }) + contextValue?: UmbTestContextConsumerClass; + } + + const controller = new MyController(element); + + await elementUpdated(element); + + expect(element.contextValue).to.equal(provider.providerInstance()); + expect(controller.contextValue).to.equal(provider.providerInstance()); + }); + + it('should have called the callback first', async () => { + let callbackCalled = false; + + class MyCallbackTestElement extends UmbLitElement { + @consumeContext({ + context: testToken, + callback: () => { + callbackCalled = true; + }, + }) + contextValue?: UmbTestContextConsumerClass; + } + + customElements.define('my-callback-consume-test-element', MyCallbackTestElement); + + const callbackElement = await fixture( + ``, + ); + + await elementUpdated(callbackElement); + + expect(callbackCalled).to.be.true; + expect(callbackElement.contextValue).to.equal(provider.providerInstance()); + }); + + it('should update the context value when the provider instance changes', async () => { + const newProviderInstance = new UmbTestContextConsumerClass(); + newProviderInstance.prop = 'new value from provider'; + + const newProvider = new UmbContextProvider(element, testToken, newProviderInstance); + newProvider.hostConnected(); + + await elementUpdated(element); + + expect(element.contextValue).to.equal(newProvider.providerInstance()); + expect(element.contextValue?.prop).to.equal(newProviderInstance.prop); + }); + + it('should be able to consume without subscribing', async () => { + class MyNoSubscribeTestController extends UmbControllerBase { + @consumeContext({ context: testToken, subscribe: false }) + contextValue?: UmbTestContextConsumerClass; + } + + const controller = new MyNoSubscribeTestController(element); + await aTimeout(0); // Wait a tick for promise to resolve + + expect(controller.contextValue).to.equal(provider.providerInstance()); + + const newProviderInstance = new UmbTestContextConsumerClass(); + newProviderInstance.prop = 'new value from provider'; + + const newProvider = new UmbContextProvider(element, testToken, newProviderInstance); + newProvider.hostConnected(); + + await aTimeout(0); // Wait a tick for promise to resolve + + // Should still be the old value + expect(controller.contextValue).to.not.equal(newProvider.providerInstance()); + expect(controller.contextValue?.prop).to.equal('value from provider'); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts new file mode 100644 index 000000000000..717c460d93f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts @@ -0,0 +1,289 @@ +/* + * Portions of this code are adapted from @lit/context + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + * + * Original source: https://github.com/lit/lit/tree/main/packages/context + * + * @license BSD-3-Clause + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { UmbContextToken } from '../token/index.js'; +import type { UmbContextMinimal } from '../types.js'; +import { UmbContextConsumerController } from './context-consumer.controller.js'; +import type { UmbContextCallback } from './context-request.event.js'; + +export interface UmbConsumeOptions< + BaseType extends UmbContextMinimal = UmbContextMinimal, + ResultType extends BaseType = BaseType, +> { + /** + * The context to consume, either as a string alias or an UmbContextToken. + */ + context: string | UmbContextToken; + + /** + * An optional callback that is invoked when the context value is set or changes. + * Note, the class instance is probably not fully constructed when this is first invoked. + * If you need to ensure the class is fully constructed, consider using a setter on the property instead. + */ + callback?: UmbContextCallback; + + /** + * If true, the context consumer will stay active and invoke the callback on context changes. + * If false, the context consumer will use asPromise() to get the value once and then clean up. + * @default true + */ + subscribe?: boolean; +} + +/** + * A property decorator that adds an UmbContextConsumerController to the component + * which will try and retrieve a value for the property via the Umbraco Context API. + * + * This decorator supports both modern "standard" decorators (Stage 3 TC39 proposal) and + * legacy TypeScript experimental decorators for backward compatibility. + * + * @param {UmbConsumeOptions} options Configuration object containing context, callback, and subscribe options + * + * @example + * ```ts + * import {consumeContext} from '@umbraco-cms/backoffice/context-api'; + * import {UMB_WORKSPACE_CONTEXT} from './workspace.context-token.js'; + * + * class MyElement extends UmbLitElement { + * // Standard decorators (with 'accessor' keyword) - Modern approach + * @consumeContext({context: UMB_WORKSPACE_CONTEXT}) + * accessor workspaceContext?: UmbWorkspaceContext; + * + * // Legacy decorators (without 'accessor') - Works with @state/@property + * @consumeContext({context: UMB_USER_CONTEXT, subscribe: false}) + * @state() + * currentUser?: UmbUserContext; + * } + * ``` + * @returns {ConsumeDecorator} A property decorator function + */ +export function consumeContext< + BaseType extends UmbContextMinimal = UmbContextMinimal, + ResultType extends BaseType = BaseType, +>(options: UmbConsumeOptions): ConsumeDecorator { + const { context, callback, subscribe = true } = options; + + return ((protoOrTarget: any, nameOrContext: PropertyKey | ClassAccessorDecoratorContext) => { + if (typeof nameOrContext === 'object') { + setupStandardDecorator(protoOrTarget, nameOrContext, context, callback, subscribe); + return; + } + + setupLegacyDecorator(protoOrTarget, nameOrContext as string, context, callback, subscribe); + }) as ConsumeDecorator; +} + +/** + * Sets up a standard decorator (Stage 3 TC39 proposal) for auto-accessors. + * This branch is used when decorating with the 'accessor' keyword. + * Example: @consumeContext({context: TOKEN}) accessor myProp?: Type; + * + * The decorator receives a ClassAccessorDecoratorContext object which provides + * addInitializer() to run code during class construction. + * + * This is the modern, standardized decorator API that will be the standard + * when Lit 4.x is released. + * + * Note: Standard decorators currently don't work with @state()/@property() + * decorators, which is why we still need the legacy branch. + */ +function setupStandardDecorator( + protoOrTarget: any, + decoratorContext: ClassAccessorDecoratorContext, + context: string | UmbContextToken, + callback: UmbContextCallback | undefined, + subscribe: boolean, +): void { + if (!('addInitializer' in decoratorContext)) { + console.warn( + '@consumeContext decorator: Standard decorator context does not support addInitializer. ' + + 'This should not happen with modern decorators.', + ); + return; + } + + decoratorContext.addInitializer(function () { + queueMicrotask(() => { + if (subscribe) { + // Continuous subscription - stays active and updates property on context changes + new UmbContextConsumerController(this, context, (value) => { + protoOrTarget.set.call(this, value); + callback?.(value); + }); + } else { + // One-time consumption - uses asPromise() to get the value once and then cleans up + const controller = new UmbContextConsumerController(this, context, callback); + controller.asPromise().then((value) => { + protoOrTarget.set.call(this, value); + }); + } + }); + }); +} + +/** + * Sets up a legacy decorator (TypeScript experimental) for regular properties. + * This branch is used when decorating without the 'accessor' keyword. + * Example: @consumeContext({context: TOKEN}) @state() myProp?: Type; + * + * The decorator receives: + * - protoOrTarget: The class prototype + * - propertyKey: The property name (string) + * + * This is the older TypeScript experimental decorator API, still widely used + * in Umbraco because it works with @state() and @property() decorators. + * The 'accessor' keyword is not compatible with these decorators yet. + * + * We support three initialization strategies: + * 1. addInitializer (if available, e.g., on LitElement classes) + * 2. hostConnected wrapper (for UmbController classes) + * 3. Warning (if neither is available) + */ +function setupLegacyDecorator( + protoOrTarget: any, + propertyKey: string, + context: string | UmbContextToken, + callback: UmbContextCallback | undefined, + subscribe: boolean, +): void { + const constructor = protoOrTarget.constructor as any; + + // Strategy 1: Use addInitializer if available (LitElement classes) + if (constructor.addInitializer) { + constructor.addInitializer((element: any): void => { + queueMicrotask(() => { + if (subscribe) { + // Continuous subscription + new UmbContextConsumerController(element, context, (value) => { + element[propertyKey] = value; + callback?.(value); + }); + } else { + // One-time consumption using asPromise() + const controller = new UmbContextConsumerController(element, context, callback); + controller.asPromise().then((value) => { + element[propertyKey] = value; + }); + } + }); + }); + return; + } + + // Strategy 2: Wrap hostConnected for UmbController classes without addInitializer + if ('hostConnected' in protoOrTarget && typeof protoOrTarget.hostConnected === 'function') { + const originalHostConnected = protoOrTarget.hostConnected; + + protoOrTarget.hostConnected = function (this: any) { + // Set up consumer once, using a flag to prevent multiple setups + if (!this.__consumeControllers) { + this.__consumeControllers = new Map(); + } + + if (!this.__consumeControllers.has(propertyKey)) { + if (subscribe) { + // Continuous subscription + const controller = new UmbContextConsumerController(this, context, (value) => { + this[propertyKey] = value; + callback?.(value); + }); + this.__consumeControllers.set(propertyKey, controller); + } else { + // One-time consumption using asPromise() + const controller = new UmbContextConsumerController(this, context, callback); + controller.asPromise().then((value) => { + this[propertyKey] = value; + }); + // Don't store in map since it cleans itself up + } + } + + // Call original hostConnected if it exists + originalHostConnected?.call(this); + }; + return; + } + + // Strategy 3: No supported initialization method available + console.warn( + `@consumeContext applied to ${constructor.name}.${propertyKey} but neither addInitializer nor hostConnected is available. ` + + `Make sure the class extends UmbLitElement, UmbControllerBase, or implements UmbController with hostConnected.`, + ); +} + +/** + * Generates a public interface type that removes private and protected fields. + * This allows accepting otherwise incompatible versions of the type (e.g. from + * multiple copies of the same package in `node_modules`). + */ +type Interface = { + [K in keyof T]: T[K]; +}; + +declare class ReactiveElement { + static addInitializer?: (initializer: (instance: any) => void) => void; +} + +declare class ReactiveController { + hostConnected?: () => void; +} + +/** + * A type representing the base class of which the decorator should work + * requiring either addInitializer (UmbLitElement) or hostConnected (UmbController). + */ +type ReactiveEntity = ReactiveElement | ReactiveController; + +type ConsumeDecorator = { + // legacy + >( + protoOrDescriptor: Proto, + name?: K, + ): FieldMustMatchProvidedType; + + // standard + , V extends ValueType>( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, + ): void; +}; + +// Note TypeScript requires the return type of a decorator to be `void | any` +type DecoratorReturn = void | any; + +type FieldMustMatchProvidedType = + // First we check whether the object has the property as a required field + Obj extends Record + ? // Ok, it does, just check whether it's ok to assign the + // provided type to the consuming field + [ProvidedType] extends [ConsumingType] + ? DecoratorReturn + : { + message: 'provided type not assignable to consuming field'; + provided: ProvidedType; + consuming: ConsumingType; + } + : // Next we check whether the object has the property as an optional field + Obj extends Partial> + ? // Check assignability again. Note that we have to include undefined + // here on the consuming type because it's optional. + [ProvidedType] extends [ConsumingType | undefined] + ? DecoratorReturn + : { + message: 'provided type not assignable to consuming field'; + provided: ProvidedType; + consuming: ConsumingType | undefined; + } + : // Ok, the field isn't present, so either someone's using consume + // manually, i.e. not as a decorator (maybe don't do that! but if you do, + // you're on your own for your type checking, sorry), or the field is + // private, in which case we can't check it. + DecoratorReturn; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts index bcc6ded36097..4372b6937792 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts @@ -191,13 +191,21 @@ export class UmbContextConsumer< cancelAnimationFrame(this.#raf); } + const hostElement = this._retrieveHost(); + + // Add connection check to prevent requesting on disconnected elements + if (hostElement && !hostElement.isConnected) { + console.warn('UmbContextConsumer: Attempting to request context on disconnected element', hostElement); + return; + } + const event = new UmbContextRequestEventImplementation( this.#contextAlias, this.#apiAlias, this._onResponse, this.#stopAtContextMatch, ); - (this.#skipHost ? this._retrieveHost()?.parentNode : this._retrieveHost())?.dispatchEvent(event); + (this.#skipHost ? hostElement?.parentNode : hostElement)?.dispatchEvent(event); if (this.#promiseResolver && this.#promiseOptions?.preventTimeout !== true) { this.#raf = requestAnimationFrame(() => { diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts index bc1302658ce4..0f2ec13c2050 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts @@ -1,3 +1,4 @@ +export * from './context-consume.decorator.js'; export * from './context-consumer.controller.js'; export * from './context-consumer.js'; export * from './context-request.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts new file mode 100644 index 000000000000..9965e827237b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts @@ -0,0 +1,111 @@ +import { UmbContextToken } from '../token/context-token.js'; +import type { UmbContextMinimal } from '../types.js'; +import { provideContext } from './context-provide.decorator.js'; +import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +class UmbTestContextConsumerClass implements UmbContextMinimal { + public prop: string; + + constructor(initialValue = 'value from provider') { + this.prop = initialValue; + } + + getHostElement() { + return document.body; + } +} + +const testToken = new UmbContextToken('my-test-context', 'testApi'); + +class MyTestRootElement extends UmbLitElement { + @provideContext({ context: testToken }) + providerInstance = new UmbTestContextConsumerClass(); +} + +customElements.define('my-provide-test-element', MyTestRootElement); + +class MyTestElement extends UmbLitElement { + contextValue?: UmbTestContextConsumerClass; + + constructor() { + super(); + + this.consumeContext(testToken, (value) => { + this.contextValue = value; + }); + } +} + +customElements.define('my-consume-test-element', MyTestElement); + +describe('@provide decorator', () => { + let rootElement: MyTestRootElement; + let element: MyTestElement; + + beforeEach(async () => { + rootElement = await fixture( + ``, + ); + element = rootElement.querySelector('my-consume-test-element') as MyTestElement; + }); + + afterEach(() => {}); + + it('should receive a context value when provided on the host', () => { + expect(element.contextValue).to.equal(rootElement.providerInstance); + }); + + it('should work when the decorator is used in a controller', async () => { + class MyController extends UmbControllerBase { + @provideContext({ context: testToken }) + providerInstance = new UmbTestContextConsumerClass('new value'); + } + + const controller = new MyController(element); + + await elementUpdated(element); + + expect(element.contextValue).to.equal(controller.providerInstance); + expect(controller.providerInstance.prop).to.equal('new value'); + }); + + it('should not update the instance when the property changes', async () => { + // we do not support setting a new value on a provided property + // as it would require a lot more logic to handle updating the context consumers + // So for now we just warn the user that this is not supported + // This might be revisited in the future if there is a need for it + + const originalProviderInstance = rootElement.providerInstance; + + const newProviderInstance = new UmbTestContextConsumerClass('new value from provider'); + rootElement.providerInstance = newProviderInstance; + + await aTimeout(0); + + expect(element.contextValue).to.equal(originalProviderInstance); + expect(element.contextValue?.prop).to.equal(originalProviderInstance.prop); + }); + + it('should update the context value when the provider instance is replaced', async () => { + const newProviderInstance = new UmbTestContextConsumerClass(); + newProviderInstance.prop = 'new value from provider'; + + class MyUpdateTestElement extends UmbLitElement { + @provideContext({ context: testToken }) + providerInstance = newProviderInstance; + } + customElements.define('my-update-provide-test-element', MyUpdateTestElement); + + const newProvider = await fixture( + ``, + ); + const element = newProvider.querySelector('my-consume-test-element') as MyTestElement; + + await elementUpdated(element); + + expect(element.contextValue).to.equal(newProviderInstance); + expect(element.contextValue?.prop).to.equal(newProviderInstance.prop); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts new file mode 100644 index 000000000000..621009856209 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts @@ -0,0 +1,258 @@ +/* + * Portions of this code are adapted from @lit/context + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + * + * Original source: https://github.com/lit/lit/tree/main/packages/context + * + * @license BSD-3-Clause + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { UmbContextToken } from '../token/index.js'; +import type { UmbContextMinimal } from '../types.js'; +import { UmbContextProviderController } from './context-provider.controller.js'; + +export interface UmbProvideOptions { + context: string | UmbContextToken; +} + +/** + * A property decorator that creates an UmbContextProviderController to provide + * a context value to child elements via the Umbraco Context API. + * + * This decorator supports both modern "standard" decorators (Stage 3 TC39 proposal) and + * legacy TypeScript experimental decorators for backward compatibility. + * + * The provider is created once during initialization with the property's initial value. + * To update the provided value dynamically, keep a state inside the provided context instance + * and update that state as needed. The context instance itself should remain the same. + * You can use any of the Umb{*}State classes. + * + * @param {UmbProvideOptions} options Configuration object containing the context token + * + * @example + * ```ts + * import {provideContext} from '@umbraco-cms/backoffice/context-api'; + * import {UMB_WORKSPACE_CONTEXT} from './workspace.context-token.js'; + * + * class MyWorkspaceElement extends UmbLitElement { + * // Standard decorators - requires 'accessor' keyword + * @provideContext({context: UMB_WORKSPACE_CONTEXT}) + * accessor workspaceContext = new UmbWorkspaceContext(this); + * + * // Legacy decorators - works without 'accessor' + * @provideContext({context: UMB_WORKSPACE_CONTEXT}) + * workspaceContext = new UmbWorkspaceContext(this); + * } + * ``` + * + * @example + * ```ts + * // For dynamic updates, store the state inside the context instance + * class MyContext extends UmbControllerBase { + * someProperty = new UmbStringState('initial value'); + * } + * + * class MyElement extends UmbLitElement { + * @provideContext({context: MY_CONTEXT}) + * private _myContext = new MyContext(this); + * + * updateValue(newValue: string) { + * this._myContext.someProperty.setValue(newValue); + * } + * } + * ``` + * + * @returns {ProvideDecorator} A property decorator function + */ +export function provideContext< + BaseType extends UmbContextMinimal = UmbContextMinimal, + ResultType extends BaseType = BaseType, + InstanceType extends ResultType = ResultType, +>(options: UmbProvideOptions): ProvideDecorator { + const { context } = options; + + return (( + protoOrTarget: any, + nameOrContext: PropertyKey | ClassAccessorDecoratorContext, + ): void | any => { + if (typeof nameOrContext === 'object') { + return setupStandardDecorator(protoOrTarget, context); + } + + setupLegacyDecorator(protoOrTarget, nameOrContext as string, context); + }) as ProvideDecorator; +} + +/** + * Sets up a standard decorator (Stage 3 TC39 proposal) for auto-accessors. + * This branch is used when decorating with the 'accessor' keyword. + * Example: @provideContext({context: TOKEN}) accessor myProp = new MyContext(); + * + * The decorator receives a ClassAccessorDecoratorContext object and returns + * an accessor descriptor that intercepts the property initialization. + * + * This is the modern, standardized decorator API that will be the standard + * when Lit 4.x is released. + * + * Note: Standard decorators currently don't work with @state()/@property() + * decorators, which is why we still need the legacy branch. + */ +function setupStandardDecorator< + BaseType extends UmbContextMinimal, + ResultType extends BaseType, + InstanceType extends ResultType, +>(protoOrTarget: any, context: string | UmbContextToken) { + return { + get(this: any) { + return protoOrTarget.get.call(this); + }, + set(this: any, value: InstanceType) { + return protoOrTarget.set.call(this, value); + }, + init(this: any, value: InstanceType) { + // Defer controller creation to avoid timing issues with private fields + queueMicrotask(() => { + new UmbContextProviderController(this, context, value); + }); + return value; + }, + }; +} + +/** + * Sets up a legacy decorator (TypeScript experimental) for regular properties. + * This branch is used when decorating without the 'accessor' keyword. + * Example: @provideContext({context: TOKEN}) myProp = new MyContext(); + * + * The decorator receives: + * - protoOrTarget: The class prototype + * - propertyKey: The property name (string) + * + * This is the older TypeScript experimental decorator API, still widely used + * in Umbraco because it works with @state() and @property() decorators. + * The 'accessor' keyword is not compatible with these decorators yet. + * + * We support three initialization strategies: + * 1. addInitializer (if available, e.g., on LitElement classes) + * 2. hostConnected wrapper (for UmbController classes) + * 3. Warning (if neither is available) + */ +function setupLegacyDecorator< + BaseType extends UmbContextMinimal, + ResultType extends BaseType, + InstanceType extends ResultType, +>(protoOrTarget: any, propertyKey: string, context: string | UmbContextToken): void { + const constructor = protoOrTarget.constructor as any; + + // Strategy 1: Use addInitializer if available (LitElement classes) + if (constructor.addInitializer) { + constructor.addInitializer((element: any): void => { + // Defer controller creation to avoid timing issues with private fields + queueMicrotask(() => { + const initialValue = element[propertyKey]; + new UmbContextProviderController(element, context, initialValue); + }); + }); + return; + } + + // Strategy 2: Wrap hostConnected for UmbController classes without addInitializer + if ('hostConnected' in protoOrTarget && typeof protoOrTarget.hostConnected === 'function') { + const originalHostConnected = protoOrTarget.hostConnected; + + protoOrTarget.hostConnected = function (this: any) { + // Set up provider once, using a flag to prevent multiple setups + if (!this.__provideControllers) { + this.__provideControllers = new Map(); + } + + if (!this.__provideControllers.has(propertyKey)) { + const initialValue = this[propertyKey]; + new UmbContextProviderController(this, context, initialValue); + // Mark as set up to prevent duplicate providers + this.__provideControllers.set(propertyKey, true); + } + + // Call original hostConnected if it exists + originalHostConnected?.call(this); + }; + return; + } + + // Strategy 3: No supported initialization method available + console.warn( + `@provideContext applied to ${constructor.name}.${propertyKey} but neither addInitializer nor hostConnected is available. ` + + `Make sure the class extends UmbLitElement, UmbControllerBase, or implements UmbController with hostConnected.`, + ); +} + +/** + * Generates a public interface type that removes private and protected fields. + * This allows accepting otherwise compatible versions of the type (e.g. from + * multiple copies of the same package in `node_modules`). + */ +type Interface = { + [K in keyof T]: T[K]; +}; + +declare class ReactiveElement { + static addInitializer?: (initializer: (instance: any) => void) => void; +} + +declare class ReactiveController { + hostConnected?: () => void; +} + +/** + * A type representing the base class of which the decorator should work + * requiring either addInitializer (UmbLitElement) or hostConnected (UmbController). + */ +type ReactiveEntity = ReactiveElement | ReactiveController; + +type ProvideDecorator = { + // legacy + >( + protoOrDescriptor: Proto, + name?: K, + ): FieldMustMatchContextType; + + // standard + , V extends ContextType>( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, + ): void; +}; + +// Note TypeScript requires the return type of a decorator to be `void | any` +type DecoratorReturn = void | any; + +type FieldMustMatchContextType = + // First we check whether the object has the property as a required field + Obj extends Record + ? // Ok, it does, just check whether it's ok to assign the + // provided type to the consuming field + [ProvidingType] extends [ContextType] + ? DecoratorReturn + : { + message: 'providing field not assignable to context'; + context: ContextType; + provided: ProvidingType; + } + : // Next we check whether the object has the property as an optional field + Obj extends Partial> + ? // Check assignability again. Note that we have to include undefined + // here on the providing type because it's optional. + [Providing | undefined] extends [ContextType] + ? DecoratorReturn + : { + message: 'providing field not assignable to context'; + context: ContextType; + consuming: Providing | undefined; + } + : // Ok, the field isn't present, so either someone's using provide + // manually, i.e. not as a decorator (maybe don't do that! but if you do, + // you're on your own for your type checking, sorry), or the field is + // private, in which case we can't check it. + DecoratorReturn; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts index 3690c440fd8e..ecad303f1961 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts @@ -1,3 +1,4 @@ +export * from './context-provide.decorator.js'; export * from './context-boundary.js'; export * from './context-boundary.controller.js'; export * from './context-provide.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts index fa179f05e791..96872fa203a9 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts @@ -44,7 +44,7 @@ class UmbLogViewerMessagesData extends UmbMockDBBase { } getLevelCount() { - const levels = this.data.map((log) => log.level ?? 'unknown'); + const levels = this.data.map((log) => log.level?.toLowerCase() ?? 'unknown'); const counts = {}; levels.forEach((level: string) => { //eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts index 9fbaebc38955..4b02a555c65c 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts @@ -1,4 +1,5 @@ const { rest } = window.MockServiceWorker; +import type { UmbMockDocumentModel } from '../../data/document/document.data.js'; import { umbDocumentMockDb } from '../../data/document/document.db.js'; import { items as referenceData } from '../../data/tracked-reference.data.js'; import { UMB_SLUG } from './slug.js'; @@ -77,8 +78,14 @@ export const detailHandlers = [ rest.get(umbracoPath(`${UMB_SLUG}/:id/available-segment-options`), (req, res, ctx) => { const id = req.params.id as string; if (!id) return res(ctx.status(400)); - const document = umbDocumentMockDb.detail.read(id); - if (!document) return res(ctx.status(404)); + + let document: UmbMockDocumentModel | null = null; + + try { + document = umbDocumentMockDb.detail.read(id); + } catch { + return res(ctx.status(404)); + } const availableSegments = document.variants.filter((v) => !!v.segment).map((v) => v.segment!) ?? []; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index 5392d3e63f3d..da0777759959 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -198,6 +198,9 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen @state() private _isReadOnly: boolean = false; + @state() + private _limitMax?: number; + constructor() { super(); @@ -294,6 +297,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen } async #setupRangeValidation(rangeLimit: UmbNumberRangeValueType | undefined) { + this._limitMax = rangeLimit?.max; if (this.#rangeUnderflowValidator) { this.removeValidator(this.#rangeUnderflowValidator); this.#rangeUnderflowValidator = undefined; @@ -408,6 +412,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen } #renderCreateButtonGroup() { + if (this._limitMax === 1 && this._layoutEntries.length > 0) return nothing; if (this._areaKey === null || this._layoutEntries.length === 0) { return html` ${this.#renderCreateButton()} ${this.#renderPasteButton()} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts index 8ab27986e192..7b0ec0e6a590 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts @@ -19,7 +19,11 @@ import type { } from '@umbraco-cms/backoffice/property-editor'; import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; -import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import { + UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + UmbFormControlMixin, + UmbValidationContext, +} from '@umbraco-cms/backoffice/validation'; import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type'; import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs'; @@ -77,6 +81,10 @@ export class UmbPropertyEditorUIBlockGridElement return this.#readonly; } #readonly = false; + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; @state() private _layoutColumns?: number; @@ -112,6 +120,16 @@ export class UmbPropertyEditorUIBlockGridElement constructor() { super(); + this.addValidator( + 'valueMissing', + () => this.mandatoryMessage, + () => { + if (!this.mandatory || this.readonly) return false; + const count = this.value?.layout?.[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.length ?? 0; + return count === 0; + }, + ); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { if (context) { this.observe( diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/inline-list-block/inline-list-block.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/inline-list-block/inline-list-block.element.ts index fd379b6f28ba..b67c2e60eaea 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/inline-list-block/inline-list-block.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/inline-list-block/inline-list-block.element.ts @@ -292,10 +292,6 @@ export class UmbInlineListBlockElement extends UmbLitElement { padding-left: var(--uui-size-2, 6px); } - #name { - font-weight: 700; - } - uui-tag { margin-left: 0.5em; margin-bottom: -0.3em; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts index 76b7e263ba75..8b48ca9d82bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts @@ -394,11 +394,8 @@ export class UmbPropertyEditorUIBlockListElement } #renderCreateButtonGroup() { - if (this.readonly && this._layouts.length > 0) { - return nothing; - } else { - return html`${this.#renderCreateButton()}${this.#renderPasteButton()}`; - } + if (this._layouts.length > 0 && (this._limitMax === 1 || this.readonly)) return nothing; + return html`${this.#renderCreateButton()}${this.#renderPasteButton()}`; } #renderInlineCreateButton(index: number) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts index 08d3760117dd..f6ceed0051c0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts @@ -194,11 +194,9 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< #renderClipboard() { return html` - - - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts index b3e273cf94e5..cfd5de0ea05c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts @@ -27,12 +27,10 @@ export class UmbClipboardEntryPickerModalElement extends UmbModalBaseElement< override render() { return html` - - - +
diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts index 9f67b19f5cd6..46e9c393d73e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts @@ -1,6 +1,7 @@ import { UmbClipboardCollectionRepository } from '../../collection/index.js'; import type { UmbClipboardEntryDetailModel } from '../types.js'; -import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import UmbClipboardEntryDetailRepository from '../detail/clipboard-entry-detail.repository.js'; +import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbEntityContext } from '@umbraco-cms/backoffice/entity'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { @@ -10,6 +11,7 @@ import { import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; // TODO: make this into an extension point (Picker) with two kinds of pickers: tree-item-picker and collection-item-picker; @customElement('umb-clipboard-entry-picker') @@ -28,6 +30,8 @@ export class UmbClipboardEntryPickerElement extends UmbLitElement { #entityContext = new UmbEntityContext(this); #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + #clipboardDetailRepository = new UmbClipboardEntryDetailRepository(this); + constructor() { super(); this.#entityContext.setEntityType('clipboard-entry'); @@ -117,17 +121,57 @@ export class UmbClipboardEntryPickerElement extends UmbLitElement { } }; + async #clearClipboard() { + try { + await umbConfirmModal(this, { + headline: '#clipboard_labelForClearClipboard', + content: '#clipboard_confirmClearDescription', + color: 'danger', + confirmLabel: '#general_clear', + cancelLabel: '#general_cancel', + }); + } catch { + return; + } + + for (const item of this._items) { + const { error } = await this.#clipboardDetailRepository.delete(item.unique); + if (error) { + console.error(`Unable to delete clipboard item with unique ${item.unique}`, error); + } + } + + this.#requestItems(); + } + override render() { - return when( - this._items.length > 0, - () => - repeat( - this._items, - (item) => item.unique, - (item) => this.#renderItem(item), - ), - () => html`

There are no items in the clipboard.

`, - ); + return html` + + + ${when( + this._items.length > 0, + () => html` + + + Clear Clipboard + + `, + () => nothing, + )} + + + ${when( + this._items.length > 0, + () => + repeat( + this._items, + (item) => item.unique, + (item) => this.#renderItem(item), + ), + () => html`

There are no items in the clipboard.

`, + )} +
+ `; } #renderItem(item: UmbClipboardEntryDetailModel) { @@ -156,7 +200,7 @@ export class UmbClipboardEntryPickerElement extends UmbLitElement { slot="actions" .entityType=${item.entityType} .unique=${item.unique} - .label=${this.localize.term('actions_viewActionsFor', [item.name])}> + .label=${this.localize.string(item.name ?? '')}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/property-editor-ui-code-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/property-editor-ui-code-editor.element.ts index 7d004f569306..50b68ae04820 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/property-editor-ui-code-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/property-editor-ui-code-editor.element.ts @@ -9,11 +9,15 @@ import type { } from '@umbraco-cms/backoffice/property-editor'; import '../components/code-editor.element.js'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; const elementName = 'umb-property-editor-ui-code-editor'; @customElement(elementName) -export class UmbPropertyEditorUICodeEditorElement extends UmbLitElement implements UmbPropertyEditorUiElement { +export class UmbPropertyEditorUICodeEditorElement + extends UmbFormControlMixin(UmbLitElement) + implements UmbPropertyEditorUiElement +{ #defaultLanguage: CodeEditorLanguage = 'javascript'; @state() @@ -31,8 +35,9 @@ export class UmbPropertyEditorUICodeEditorElement extends UmbLitElement implemen @state() private _wordWrap = false; - @property() - value = ''; + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; @property({ attribute: false }) public set config(config: UmbPropertyEditorConfigCollection | undefined) { @@ -53,6 +58,16 @@ export class UmbPropertyEditorUICodeEditorElement extends UmbLitElement implemen this.dispatchEvent(new UmbChangeEvent()); } + constructor() { + super(); + + this.addValidator( + 'valueMissing', + () => this.mandatoryMessage, + () => !!this.mandatory && (!this.value || this.value.length === 0), + ); + } + override render() { return html` | undefined) { @@ -49,7 +49,6 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { this._property = value; this.#context.setAlias(value?.alias); this.#context.setLabel(value?.name); - this.#checkAliasAutoGenerate(this._property?.unique); this.#checkInherited(); this.#setDataType(this._property?.dataType?.unique); this.requestUpdate('property', oldValue); @@ -86,20 +85,6 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { @state() private _dataTypeName?: string; - @state() - private _aliasLocked = true; - - #autoGenerateAlias = true; - - #checkAliasAutoGenerate(unique: string | undefined) { - if (unique === this.#propertyUnique) return; - this.#propertyUnique = unique; - - if (this.#context.getAlias()) { - this.#autoGenerateAlias = false; - } - } - async #checkInherited() { if (this._propertyStructureHelper && this._property) { // We can first match with something if we have a name [NL] @@ -131,19 +116,6 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { this._propertyStructureHelper.partialUpdateProperty(this._property.unique, partialObject); } - #onToggleAliasLock(event: CustomEvent) { - if (!this.property?.alias && (event.target as UUIInputLockElement).locked) { - this.#autoGenerateAlias = true; - } else { - this.#autoGenerateAlias = false; - } - - this._aliasLocked = !this._aliasLocked; - if (!this._aliasLocked) { - (event.target as UUIInputElement)?.focus(); - } - } - async #setDataType(dataTypeUnique: string | undefined) { if (!dataTypeUnique) { this._dataTypeName = undefined; @@ -173,28 +145,23 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { this._propertyStructureHelper?.removeProperty(unique); } - #onAliasChanged(event: UUIInputEvent) { - this.#singleValueUpdate('alias', event.target.value.toString()); - } - - #onNameChanged(event: UUIInputEvent) { - const newName = event.target.value.toString(); - if (this.#autoGenerateAlias) { - this.#singleValueUpdate('alias', generateAlias(newName ?? '')); - } - this.#singleValueUpdate('name', newName); + #onNameAliasChange(e: InputEvent & { target: UmbInputWithAliasElement }) { + this.#partialUpdate({ + name: e.target.value, + alias: e.target.alias, + } as UmbPropertyTypeModel); } override render() { // TODO: Only show alias on label if user has access to DocumentType within settings: [NL] - return this._inherited ? this.renderInheritedProperty() : this.renderEditableProperty(); + return this._inherited ? this.#renderInheritedProperty() : this.#renderEditableProperty(); } - renderInheritedProperty() { + #renderInheritedProperty() { if (!this.property) return; if (this.sortModeActive) { - return this.renderSortableProperty(); + return this.#renderSortableProperty(); } else { return html`
- ${this.renderPropertyTags()} + ${this.#renderPropertyName()} ${this.#renderPropertyTags()} ${this._inherited ? html` @@ -220,22 +187,27 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { } } - renderEditableProperty() { + #renderEditableProperty() { if (!this.property || !this.editPropertyTypePath) return; if (this.sortModeActive) { - return this.renderSortableProperty(); + return this.#renderSortableProperty(); } else { return html` + - + this.#onClick()}> ${repeat( @@ -82,12 +104,26 @@ export class UmbPreviewSegmentElement extends UmbLitElement { --uui-button-font-weight: 400; --uui-button-padding-left-factor: 3; --uui-button-padding-right-factor: 3; + --uui-menu-item-flat-structure: 1; + } + + :host([hidden]) { + display: none; + } + + #expand-symbol { + transform: rotate(-90deg); + margin-left: var(--uui-size-space-3, 9px); + + &[open] { + transform: rotate(0deg); + } } uui-button > div { display: flex; align-items: center; - gap: 5px; + gap: var(--uui-size-2, 6px); } umb-popover-layout { diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/types.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/types.ts new file mode 100644 index 000000000000..8284655869c7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/types.ts @@ -0,0 +1,6 @@ +/** + * Custom event type for popover toggle events + */ +export interface UmbPopoverToggleEvent extends Event { + newState: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/repository/index.ts new file mode 100644 index 000000000000..7f9694a5bc4f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/repository/index.ts @@ -0,0 +1 @@ +export { UmbPreviewRepository } from './preview.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/repository/preview.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/repository/preview.repository.ts new file mode 100644 index 000000000000..c3172c6e308c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/repository/preview.repository.ts @@ -0,0 +1,74 @@ +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { DocumentService, PreviewService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { DocumentUrlInfoModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbPreviewRepository extends UmbRepositoryBase { + constructor(host: UmbControllerHost) { + super(host); + } + + /** + * Gets the preview URL for a document. + * @param {string} unique The unique identifier of the document. + * @param {string} providerAlias The alias of the URL provider registered on the server. + * @param {string | undefined} culture The culture to preview (undefined means invariant). + * @param {string | undefined} segment The segment to preview (undefined means no specific segment). + * @returns {DocumentUrlInfoModel} The preview URLs of the document. + * @memberof UmbPreviewRepository + */ + async getPreviewUrl( + unique: string, + providerAlias: string, + culture?: string, + segment?: string, + ): Promise { + const { data, error } = await tryExecute( + this, + DocumentService.getDocumentByIdPreviewUrl({ + path: { id: unique }, + query: { providerAlias, culture, segment }, + }), + ); + + if (error) { + throw new Error(error.message); + } + + return data; + } + + /** + * Gets the published URL info for a document. + * @param {string} unique The unique identifier of the document. + * @param {string | undefined} culture The culture to preview (undefined means invariant). + * @returns {DocumentUrlInfoModel} The published URL info of the document. + * @memberof UmbPreviewRepository + */ + async getPublishedUrl(unique: string, culture?: string): Promise { + if (!unique) return null; + + const { data, error } = await tryExecute(this, DocumentService.getDocumentUrls({ query: { id: [unique] } })); + + if (error) { + throw new Error(error.message); + } + + if (!data?.length) return null; + + // TODO: [LK] Review the logic here, unsure whether this is correct. When will the array have more than one item? + const urlInfo = culture ? data[0].urlInfos.find((x) => x.culture === culture) : data[0].urlInfos[0]; + return urlInfo; + } + + /** + * Exits preview mode. + * @returns {Promise} + * @memberof UmbPreviewRepository + */ + async exit(): Promise { + await tryExecute(this, PreviewService.deletePreview(), { disableNotifications: true }); + return; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/umbraco-package.ts new file mode 100644 index 000000000000..b86e47d21b8a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.Preview'; +export const extensions = [ + { + name: 'Preview Bundle', + alias: 'Umb.Bundle.Preview', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/vite.config.ts new file mode 100644 index 000000000000..c667d802cafd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/preview'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/icon-picker/property-editor-ui-icon-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/icon-picker/property-editor-ui-icon-picker.element.ts index fffedbbaec1e..8c9814bf5168 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/icon-picker/property-editor-ui-icon-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/icon-picker/property-editor-ui-icon-picker.element.ts @@ -9,28 +9,45 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { extractUmbColorVariable } from '@umbraco-cms/backoffice/resources'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; + /** * @element umb-property-editor-ui-icon-picker */ @customElement('umb-property-editor-ui-icon-picker') -export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implements UmbPropertyEditorUiElement { - // +export class UmbPropertyEditorUIIconPickerElement + extends UmbFormControlMixin(UmbLitElement, undefined) + implements UmbPropertyEditorUiElement +{ + @property({ type: Boolean }) + mandatory = false; + + protected override firstUpdated(): void { + this.addValidator( + 'valueMissing', + () => 'Icon is required', + () => this.mandatory && !this._icon, + ); + } + @property() - public set value(v: string) { - this._value = v ?? ''; - const parts = this._value.split(' '); + public override set value(v: string) { + const val = v ?? ''; + super.value = val; + + const parts = val.split(' '); if (parts.length === 2) { this._icon = parts[0]; this._color = parts[1].replace('color-', ''); } else { - this._icon = this._value; + this._icon = val; this._color = ''; } } - public get value() { - return this._value; + + public override get value() { + return (super.value as string) ?? ''; } - private _value = ''; @state() private _icon = ''; @@ -53,7 +70,7 @@ export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implemen icon: this._icon, color: this._color, }, - data: { placeholder: this._placeholderIcon }, + data: { placeholder: this._placeholderIcon, showEmptyOption: !this.mandatory }, }).catch(() => undefined); if (!data) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/number-range/property-editor-ui-number-range.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/number-range/property-editor-ui-number-range.element.ts index aaeeb1a5aef6..792276779bae 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/number-range/property-editor-ui-number-range.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/number-range/property-editor-ui-number-range.element.ts @@ -1,6 +1,6 @@ import type { UmbInputNumberRangeElement } from '@umbraco-cms/backoffice/components'; import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; @@ -26,9 +26,14 @@ export class UmbPropertyEditorUINumberRangeElement @state() private _validationRange?: UmbNumberRangeValueType; + @property({ type: Boolean }) + mandatory = false; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + @property({ type: Object }) public override set value(value: UmbNumberRangeValueType | undefined) { - super.value = value || { min: undefined, max: undefined }; + super.value = value; this._minValue = value?.min; this._maxValue = value?.max; } @@ -42,7 +47,9 @@ export class UmbPropertyEditorUINumberRangeElement } #onChange(event: CustomEvent & { target: UmbInputNumberRangeElement }) { - this.value = { min: event.target.minValue, max: event.target.maxValue }; + const min = event.target.minValue; + const max = event.target.maxValue; + this.value = min == null && max == null ? undefined : { min, max }; this.dispatchEvent(new UmbChangeEvent()); } @@ -59,6 +66,8 @@ export class UmbPropertyEditorUINumberRangeElement diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/number/property-editor-ui-number.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/number/property-editor-ui-number.element.ts index a7a1f1656daf..91dd6d35fadf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/number/property-editor-ui-number.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/number/property-editor-ui-number.element.ts @@ -1,5 +1,5 @@ import { css, customElement, html, ifDefined, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; import type { @@ -10,7 +10,7 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; @customElement('umb-property-editor-ui-number') export class UmbPropertyEditorUINumberElement - extends UmbFormControlMixin(UmbLitElement) + extends UmbFormControlMixin(UmbLitElement, undefined) implements UmbPropertyEditorUiElement { /** @@ -22,6 +22,15 @@ export class UmbPropertyEditorUINumberElement @property({ type: Boolean, reflect: true }) readonly = false; + /** + * Sets the input to mandatory, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + @state() private _label?: string; @@ -78,6 +87,7 @@ export class UmbPropertyEditorUINumberElement this, ); } + this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!); } #parseNumber(input: unknown): number | undefined { @@ -103,6 +113,8 @@ export class UmbPropertyEditorUINumberElement placeholder=${ifDefined(this._placeholder)} value=${this.value?.toString() ?? ''} @change=${this.#onChange} + ?required=${this.mandatory} + .requiredMessage=${this.mandatoryMessage} ?readonly=${this.readonly}> `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/property-editor-ui-slider.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/property-editor-ui-slider.element.ts index 8e3de47b85ea..9cef6edb21b1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/property-editor-ui-slider.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/property-editor-ui-slider.element.ts @@ -8,14 +8,23 @@ import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, } from '@umbraco-cms/backoffice/property-editor'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +/** + * + * @param value + */ function stringToValueObject(value: string | undefined): Partial { const [from, to] = (value ?? ',').split(','); const fromNumber = makeNumberOrUndefined(from); return { from: fromNumber, to: makeNumberOrUndefined(to, fromNumber) }; } +/** + * + * @param value + * @param fallback + */ function makeNumberOrUndefined(value: string | undefined, fallback?: undefined | number) { if (value === undefined) { return fallback; @@ -27,6 +36,11 @@ function makeNumberOrUndefined(value: string | undefined, fallback?: undefined | return n; } +/** + * + * @param value + * @param fallback + */ function undefinedFallback(value: number | undefined, fallback: number) { return value === undefined ? fallback : value; } @@ -48,6 +62,16 @@ export class UmbPropertyEditorUISliderElement @property({ type: Boolean, reflect: true }) readonly = false; + /** + * Sets the input to mandatory, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + mandatory?: boolean; + + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + @state() private _enableRange = false; @@ -139,7 +163,9 @@ export class UmbPropertyEditorUISliderElement .max=${this._max} ?enable-range=${this._enableRange} @change=${this.#onChange} - ?readonly=${this.readonly}> + ?readonly=${this.readonly} + ?required=${this.mandatory} + .requiredMessage=${this.mandatoryMessage}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/slider-property-value-preset.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/slider-property-value-preset.ts index 8c27b41147d3..acbec3f810ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/slider-property-value-preset.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/slider/slider-property-value-preset.ts @@ -6,16 +6,16 @@ export class UmbSliderPropertyValuePreset implements UmbPropertyValuePreset { async processValue(value: undefined | UmbSliderPropertyEditorUiValue, config: UmbPropertyEditorConfig) { - const enableRange = Boolean(config.find((x) => x.alias === 'enableRange') ?? false); + const enableRange = Boolean(config.find((x) => x.alias === 'enableRange')?.value ?? false); /* - const min = Number(config.find((x) => x.alias === 'minVal') ?? 0); - const max = Number(config.find((x) => x.alias === 'maxVal') ?? 100); + const min = Number(config.find((x) => x.alias === 'minVal')?.value ?? 0); + const max = Number(config.find((x) => x.alias === 'maxVal')?.value ?? 100); const minVerified = isNaN(min) ? undefined : min; const maxVerified = isNaN(max) ? undefined : max; */ - const step = (config.find((x) => x.alias === 'step') as number | undefined) ?? 0; + const step = Number(config.find((x) => x.alias === 'step')?.value ?? 0); const stepVerified = step > 0 ? step : 1; const initValueMin = Number(config.find((x) => x.alias === 'initVal1')?.value) || 0; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/toggle/property-editor-ui-toggle.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/toggle/property-editor-ui-toggle.element.ts index 5c71b27a5e9e..121d06c9f5b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/toggle/property-editor-ui-toggle.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/toggle/property-editor-ui-toggle.element.ts @@ -59,8 +59,8 @@ export class UmbPropertyEditorUIToggleElement } #onChange(event: CustomEvent & { target: UmbInputToggleElement }) { - const checked = event.target.checked; - this.value = this.mandatory ? (checked ?? null) : checked; + //checked is never null/undefined + this.value = event.target.checked; this.dispatchEvent(new UmbChangeEvent()); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.context.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.context.ts index 7a6bc3826a84..75bbb7e31cfc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.context.ts @@ -1,8 +1,9 @@ import { UMB_STATIC_FILE_PICKER_MODAL } from '../../modals/index.js'; -import type { UmbStaticFilePickerModalData, UmbStaticFilePickerModalValue } from '../../modals/index.js'; import { UMB_STATIC_FILE_ITEM_REPOSITORY_ALIAS } from '../../constants.js'; +import type { UmbStaticFilePickerModalData, UmbStaticFilePickerModalValue } from '../../modals/index.js'; import type { UmbStaticFileItemModel } from '../../types.js'; import { UmbPickerInputContext } from '@umbraco-cms/backoffice/picker-input'; +import { UmbServerFilePathUniqueSerializer } from '@umbraco-cms/backoffice/server-file-system'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbStaticFilePickerInputContext extends UmbPickerInputContext< @@ -11,7 +12,14 @@ export class UmbStaticFilePickerInputContext extends UmbPickerInputContext< UmbStaticFilePickerModalData, UmbStaticFilePickerModalValue > { + #serializer = new UmbServerFilePathUniqueSerializer(); + constructor(host: UmbControllerHost) { super(host, UMB_STATIC_FILE_ITEM_REPOSITORY_ALIAS, UMB_STATIC_FILE_PICKER_MODAL); } + + protected override getItemDisplayName(item: UmbStaticFileItemModel | undefined, unique: string): string { + // If item doesn't exist, use the file path as the name + return item?.name ?? this.#serializer.toServerPath(unique) ?? unique; + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.element.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.element.ts index 9e42a0b6f7b1..e533364de471 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/components/input-static-file/input-static-file.element.ts @@ -2,9 +2,12 @@ import type { UmbStaticFileItemModel } from '../../repository/item/types.js'; import { UmbStaticFilePickerInputContext } from './input-static-file.context.js'; import { css, customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbServerFilePathUniqueSerializer } from '@umbraco-cms/backoffice/server-file-system'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { UmbRepositoryItemsStatus } from '@umbraco-cms/backoffice/repository'; + +import '@umbraco-cms/backoffice/entity-item'; @customElement('umb-input-static-file') export class UmbInputStaticFileElement extends UmbFormControlMixin( @@ -79,6 +82,9 @@ export class UmbInputStaticFileElement extends UmbFormControlMixin; + @state() + private _statuses?: Array; + #pickerContext = new UmbStaticFilePickerInputContext(this); constructor() { @@ -98,6 +104,7 @@ export class UmbInputStaticFileElement extends UmbFormControlMixin (this.value = selection.join(','))); this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems)); + this.observe(this.#pickerContext.statuses, (statuses) => (this._statuses = statuses)); } protected override getFormElement() { @@ -105,13 +112,13 @@ export class UmbInputStaticFileElement extends UmbFormControlMixin ${repeat( - this._items, - (item) => item.unique, - (item) => this._renderItem(item), + this._statuses, + (status) => status.unique, + (status) => this.#renderItem(status), )} ${this.#renderAddButton()} @@ -137,17 +144,25 @@ export class UmbInputStaticFileElement extends UmbFormControlMixin x.unique === unique); + const isError = status.state.type === 'error'; + return html` - - + this.#pickerContext.requestRemoveItem(item.unique)}> + @click=${() => this.#pickerContext.requestRemoveItem(unique)}> - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/manifests.ts index cd9c2b9c6406..261047fb1598 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/manifests.ts @@ -4,7 +4,7 @@ export const manifest: ManifestPropertyEditorUi = { type: 'propertyEditorUi', alias: 'Umb.PropertyEditorUi.StaticFilePicker', name: 'Static File Picker Property Editor UI', - js: () => import('./property-editor-ui-static-file-picker.element.js'), + element: () => import('./property-editor-ui-static-file-picker.element.js'), meta: { label: 'Static File Picker', icon: 'icon-document', diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/property-editor-ui-static-file-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/property-editor-ui-static-file-picker.element.ts index 81cfbda18ddb..d5098b7700bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/property-editor-ui-static-file-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/property-editors/static-file-picker/property-editor-ui-static-file-picker.element.ts @@ -73,6 +73,8 @@ export class UmbPropertyEditorUIStaticFilePickerElement extends UmbLitElement im } } +export { UmbPropertyEditorUIStaticFilePickerElement as element }; + export default UmbPropertyEditorUIStaticFilePickerElement; declare global { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts index 5a84f467c66a..b84e527e9c9d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts @@ -23,10 +23,14 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') @property({ type: String }) culture?: string | null; + @property({ type: Boolean }) + override required = false; + @property({ type: String }) + override requiredMessage = 'This field is required'; @property({ type: Array }) public set items(newTags: string[]) { - const newItems = newTags.filter((x) => x !== ''); + const newItems = newTags?.filter((x) => x !== '') || []; this.#items = newItems; super.value = this.#items.join(','); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tags/property-editors/tags/property-editor-ui-tags.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tags/property-editors/tags/property-editor-ui-tags.element.ts index ec160153b703..b9b8814ce9ba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tags/property-editors/tags/property-editor-ui-tags.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tags/property-editors/tags/property-editor-ui-tags.element.ts @@ -9,20 +9,22 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import '../../components/tags-input/tags-input.element.js'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; /** * @element umb-property-editor-ui-tags */ @customElement('umb-property-editor-ui-tags') -export class UmbPropertyEditorUITagsElement extends UmbLitElement implements UmbPropertyEditorUiElement { - private _value: Array = []; - +export class UmbPropertyEditorUITagsElement + extends UmbFormControlMixin, typeof UmbLitElement, undefined>(UmbLitElement, undefined) + implements UmbPropertyEditorUiElement +{ @property({ type: Array }) - public set value(value: Array) { - this._value = value || []; + public override set value(value: Array) { + super.value = value || []; } - public get value(): Array { - return this._value; + public override get value(): Array { + return super.value as string[]; } /** @@ -33,6 +35,10 @@ export class UmbPropertyEditorUITagsElement extends UmbLitElement implements Umb */ @property({ type: Boolean, reflect: true }) readonly = false; + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; @state() private _group?: string; @@ -61,6 +67,10 @@ export class UmbPropertyEditorUITagsElement extends UmbLitElement implements Umb }); } + protected override firstUpdated() { + this.addFormControlElement(this.shadowRoot!.querySelector('umb-tags-input')!); + } + #onChange(event: CustomEvent) { this.value = ((event.target as UmbTagsInputElement).value as string).split(','); this.dispatchEvent(new UmbChangeEvent()); @@ -72,6 +82,8 @@ export class UmbPropertyEditorUITagsElement extends UmbLitElement implements Umb .culture=${this._culture} .items=${this.value} @change=${this.#onChange} + ?required=${!!this.mandatory} + .requiredMessage=${this.mandatoryMessage} ?readonly=${this.readonly}>`; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts index 6ba2c40dc8c8..242d9100a719 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts @@ -11,7 +11,7 @@ export const manifests: Array = [ forEntityTypes: [UMB_PARTIAL_VIEW_ROOT_ENTITY_TYPE, UMB_PARTIAL_VIEW_FOLDER_ENTITY_TYPE], meta: { icon: 'icon-add', - label: '#actions_create', + label: '#actions_createFor', additionalOptions: true, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/manifests.ts index 68cb772ad2b0..3c94201b59ed 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/manifests.ts @@ -11,7 +11,7 @@ export const manifests: Array = [ forEntityTypes: [UMB_SCRIPT_ROOT_ENTITY_TYPE, UMB_SCRIPT_FOLDER_ENTITY_TYPE], meta: { icon: 'icon-add', - label: '#actions_create', + label: '#actions_createFor', additionalOptions: true, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts index 1ee92fff8159..4485d593ffeb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts @@ -13,7 +13,7 @@ export const manifests: Array = [ forEntityTypes: [UMB_TEMPLATE_ENTITY_TYPE, UMB_TEMPLATE_ROOT_ENTITY_TYPE], meta: { icon: 'icon-add', - label: '#actions_create', + label: '#actions_createFor', additionalOptions: true, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace.context.ts index 0ea8f97d8cdd..0951d51f308b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace.context.ts @@ -123,10 +123,9 @@ export class UmbTemplateWorkspaceContext if (updateLayoutBlock) { this.#updateMasterTemplateLayoutBlock(); + this._data.updateCurrent({ masterTemplate: unique ? { unique } : null }); } - this._data.updateCurrent({ masterTemplate: unique ? { unique } : null }); - return unique; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-picker/media-picker.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-picker/media-picker.tiptap-toolbar-api.ts index f07fd645308e..2de61bc23933 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-picker/media-picker.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-picker/media-picker.tiptap-toolbar-api.ts @@ -1,4 +1,5 @@ -import type { Editor } from '../../externals.js'; +import type { Editor, ProseMirrorNode } from '../../externals.js'; +import { NodeSelection } from '../../externals.js'; import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js'; import { getGuidFromUdi, imageSize } from '@umbraco-cms/backoffice/utils'; import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api'; @@ -41,36 +42,67 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT override async execute(editor: Editor) { const currentTarget = editor.getAttributes('image'); - const figure = editor.getAttributes('figure'); + const currentMediaUdi = this.#extractMediaUdi(currentTarget); + const currentAltText = currentTarget?.alt; + const currentCaption = this.#extractCaption(editor.state.selection); - let currentMediaUdi: string | undefined = undefined; - if (currentTarget?.['data-udi']) { - currentMediaUdi = getGuidFromUdi(currentTarget['data-udi']); - } + await this.#updateImageWithMetadata(editor, currentMediaUdi, currentAltText, currentCaption); + } - let currentAltText: string | undefined = undefined; - if (currentTarget?.alt) { - currentAltText = currentTarget.alt; - } + async #updateImageWithMetadata( + editor: Editor, + currentMediaUdi: string | undefined, + currentAltText: string | undefined, + currentCaption: string | undefined, + ) { + const mediaGuid = await this.#getMediaGuid(currentMediaUdi); + if (!mediaGuid) return; - let currentCaption: string | undefined = undefined; - if (figure?.figcaption) { - currentCaption = figure.figcaption; - } + const media = await this.#showMediaCaptionAltText(mediaGuid, currentAltText, currentCaption); + if (!media) return; - const selection = await this.#openMediaPicker(currentMediaUdi); - if (!selection?.length) return; + this.#insertInEditor(editor, mediaGuid, media); + } - const mediaGuid = selection[0]; + #extractMediaUdi(imageAttributes: Record): string | undefined { + return imageAttributes?.['data-udi'] ? getGuidFromUdi(imageAttributes['data-udi'] as string) : undefined; + } - if (!mediaGuid) { - throw new Error('No media selected'); + #extractCaption(selection: unknown): string | undefined { + if (!(selection instanceof NodeSelection)) return undefined; + if (selection.node.type.name !== 'figure') return undefined; + + return this.#findFigcaptionText(selection.node); + } + + #findFigcaptionText(figureNode: ProseMirrorNode): string | undefined { + let caption: string | undefined; + figureNode.descendants((child) => { + if (child.type.name === 'figcaption') { + caption = child.textContent || undefined; + return false; // Stop searching + } + return true; // Continue searching + }); + return caption; + } + + async #getMediaGuid(currentMediaUdi?: string): Promise { + if (currentMediaUdi) { + // Image already exists, go directly to edit alt text/caption + return currentMediaUdi; } - const media = await this.#showMediaCaptionAltText(mediaGuid, currentAltText, currentCaption); - if (!media) return; + // No image selected, open media picker + const selection = await this.#openMediaPicker(); + if (!selection?.length) return undefined; - this.#insertInEditor(editor, mediaGuid, media); + const selectedGuid = selection[0]; + if (!selectedGuid) { + throw new Error('No media selected'); + } + + return selectedGuid; } async #openMediaPicker(currentMediaUdi?: string) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/externals.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/externals.ts index a34560b3f83a..e848d669d116 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/externals.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/externals.ts @@ -14,6 +14,10 @@ export { HardBreak } from '@tiptap/extension-hard-break'; export { Paragraph } from '@tiptap/extension-paragraph'; export { Text } from '@tiptap/extension-text'; +// PROSEMIRROR TYPES +export { NodeSelection } from '@tiptap/pm/state'; +export type { Node as ProseMirrorNode } from '@tiptap/pm/model'; + // OPTIONAL EXTENSIONS export { Blockquote } from '@tiptap/extension-blockquote'; export { Bold } from '@tiptap/extension-bold'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/manifests.ts index 9394f778d2e2..6fefe64b7122 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/manifests.ts @@ -7,7 +7,7 @@ export const manifests: Array = [ name: 'Rich Text Editor [Tiptap] Property Editor UI', element: () => import('./property-editor-ui-tiptap.element.js'), meta: { - label: 'Rich Text Editor [Tiptap]', + label: '#rte_label', propertyEditorSchemaAlias: 'Umbraco.RichText', icon: 'icon-browser-window', group: 'richContent', diff --git a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-card.element.ts b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-card.element.ts new file mode 100644 index 000000000000..9e0055f406bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-card.element.ts @@ -0,0 +1,135 @@ +import { css, customElement, html, nothing, property, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { NewsDashboardItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; + +@customElement('umb-news-card') +export class UmbNewsCardElement extends UmbLitElement { + @property({ type: Object }) + item!: NewsDashboardItemResponseModel; + + @property({ type: Number }) + priority: number = 3; + + #renderHeading(priority: number, text: string) { + if (priority <= 2) return html`

${text}

`; + return html`

${text}

`; + } + + override render() { + if (!this.item) return nothing; + + const isLastRow = this.priority === 3; + + const showImage = this.priority <= 2 && !!this.item.imageUrl; + + const content = html` + ${when( + showImage, + () => + this.item.imageUrl + ? html`${this.item.imageAltText` + : html``, + () => nothing, + )} +
+ ${this.#renderHeading(this.priority, this.item.header)} + ${this.item.body ? html`
${unsafeHTML(this.item.body)}
` : nothing} + ${!isLastRow && this.item.url + ? html`
+ +
` + : nothing} +
+ `; + + // Last row: whole card is a link + return isLastRow + ? this.item.url + ? html` + + ${content} + + ` + : html`
${content}
` + : html`
${content}
`; + } + + static override styles = css` + :host { + display: block; + height: 100%; + } + + .card { + background: var(--uui-color-surface); + border-radius: var(--uui-border-radius, 8px); + box-shadow: var( + --uui-box-box-shadow, + var(--uui-shadow-depth-1, 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)) + ); + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + .card-img { + width: 100%; + object-fit: cover; + display: block; + } + + .card-img.placeholder { + height: 8px; + } + + .card-body { + display: flex; + flex-direction: column; + padding: var(--uui-size-space-5); + flex: 1 1 auto; + justify-content: space-between; + gap: var(--uui-size-space-3, 9px); + } + + .card-title { + margin: 0; + } + + .card-text > p { + margin: 0; + } + + .normal-priority { + display: block; + border: 1px solid var(--uui-color-divider); + border-radius: var(--uui-border-radius, 8px); + text-decoration: none; + color: inherit; + overflow: hidden; + + .card-body { + gap: 0; + } + } + .normal-priority:hover { + color: var(--uui-color-interactive-emphasis); + } + .card-actions { + align-self: end; + } + `; +} + +export default UmbNewsCardElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-news-card': UmbNewsCardElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-container.element.ts b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-container.element.ts new file mode 100644 index 000000000000..1ca905f829dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/components/umb-news-container.element.ts @@ -0,0 +1,110 @@ +import { css, customElement, html, nothing, property, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { NewsDashboardItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; + +import './umb-news-card.element.js'; +import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; + +@customElement('umb-news-container') +export class UmbNewsContainerElement extends UmbLitElement { + @property({ type: Array }) + items: Array = []; + + #groupItemsByPriority(items: NewsDashboardItemResponseModel[]) { + const sanitizedItems = items.map((i) => ({ + ...i, + body: i.body ? sanitizeHTML(i.body) : '', + })); + + // Separate items by priority. + const priority1 = sanitizedItems.filter((item) => item.priority === 'High'); + const priority2 = sanitizedItems.filter((item) => item.priority === 'Medium'); + const priority3 = sanitizedItems.filter((item) => item.priority === 'Normal'); + + // Group 1: First 4 items from priority 1. + const group1Items = priority1.slice(0, 4); + const overflow1 = priority1.slice(4); + + // Group 2: Overflow from priority 1 + priority 2 items (max 4 total). + const group2Items = [...overflow1, ...priority2].slice(0, 4); + const overflow2Count = overflow1.length + priority2.length - 4; + const overflow2 = overflow2Count > 0 ? [...overflow1, ...priority2].slice(4) : []; + + // Group 3: Overflow from groups 1 & 2 + priority 3 items. + const group3Items = [...overflow2, ...priority3]; + + return [ + { priority: 1, items: group1Items }, + { priority: 2, items: group2Items }, + { priority: 3, items: group3Items }, + ]; + } + + override render() { + if (!this.items?.length) return nothing; + + const groups = this.#groupItemsByPriority(this.items); + + return html` + ${repeat( + groups, + (g) => g.priority, + (g) => html` +
+ ${repeat( + g.items, + (i, idx) => i.url || i.header || idx, + (i) => html``, + )} +
+ `, + )} + `; + } + + static override styles = css` + .cards { + --cols: 4; + --gap: var(--uui-size-space-4); + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols)), 1fr)); + gap: var(--gap); + } + + .cards + .cards { + margin-top: var(--uui-size-space-5); + } + + /* For when container-type is not been assigned, not so sure about it???*/ + @media (max-width: 1200px) { + .cards { + grid-template-columns: repeat(auto-fit, minmax(2, 1fr)); + } + } + @media (max-width: 700px) { + .cards { + grid-template-columns: 1fr; + } + } + + @container dashboard (max-width: 1200px) { + .cards { + grid-template-columns: repeat(auto-fit, minmax(2, 1fr)); + } + } + @container dashboard (max-width: 700px) { + .cards { + grid-template-columns: 1fr; + } + } + `; +} + +export default UmbNewsContainerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-news-container': UmbNewsContainerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts index 5334c4de89cb..35c7df3a7fc0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts @@ -1,32 +1,16 @@ import { UmbNewsDashboardRepository } from './repository/index.js'; -import { - css, - customElement, - html, - nothing, - repeat, - state, - unsafeHTML, - when, -} from '@umbraco-cms/backoffice/external/lit'; -import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; +import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { NewsDashboardItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; -interface UmbNewsDashboardGroupedItems { - priority: number; - items: Array; -} +import './components/umb-news-container.element.js'; @customElement('umb-umbraco-news-dashboard') export class UmbUmbracoNewsDashboardElement extends UmbLitElement { @state() private _items: Array = []; - @state() - private _groupedItems: Array = []; - @state() private _loaded: boolean = false; @@ -35,40 +19,9 @@ export class UmbUmbracoNewsDashboardElement extends UmbLitElement { override async firstUpdated() { const res = await this.#repo.getNewsDashboard(); this._items = res.data?.items ?? []; - this._groupedItems = this.#groupItemsByPriority(); this._loaded = true; } - #groupItemsByPriority(): Array { - const sanitizedItems = this._items.map((i) => ({ - ...i, - body: i.body ? sanitizeHTML(i.body) : '', - })); - - // Separate items by priority. - const priority1 = sanitizedItems.filter((item) => item.priority === 'High'); - const priority2 = sanitizedItems.filter((item) => item.priority === 'Medium'); - const priority3 = sanitizedItems.filter((item) => item.priority === 'Normal'); - - // Group 1: First 4 items from priority 1. - const group1Items = priority1.slice(0, 4); - const overflow1 = priority1.slice(4); - - // Group 2: Overflow from priority 1 + priority 2 items (max 4 total). - const group2Items = [...overflow1, ...priority2].slice(0, 4); - const overflow2Count = overflow1.length + priority2.length - 4; - const overflow2 = overflow2Count > 0 ? [...overflow1, ...priority2].slice(4) : []; - - // Group 3: Overflow from groups 1 & 2 + priority 3 items. - const group3Items = [...overflow2, ...priority3]; - - return [ - { priority: 1, items: group1Items }, - { priority: 2, items: group2Items }, - { priority: 3, items: group3Items }, - ]; - } - override render() { if (!this._loaded) { return html`
`; @@ -78,58 +31,7 @@ export class UmbUmbracoNewsDashboardElement extends UmbLitElement { return this.#renderDefaultContent(); } - return html` - ${repeat( - this._groupedItems, - (g) => g.priority, - (g) => html` -
- ${repeat( - g.items, - (i, idx) => i.url || i.header || idx, - (i) => { - const isLastRow = g.priority === 3; - - const content = html` - ${when( - g.priority <= 2, - () => - html`${i.imageUrl - ? html`${i.imageAltText` - : html``}`, - () => nothing, - )} -
- ${g.priority <= 2 - ? html`

${i.header}

` - : html`

${i.header}

`} - ${i.body ? html`
${unsafeHTML(i.body)}
` : null} - ${!isLastRow && i.url - ? html`
- - ${i.buttonText || 'Open'} - -
` - : nothing} -
- `; - - // LAST ROW: whole card is a link - return isLastRow - ? i.url - ? html` - - ${content} - - ` - : html`
${content}
` - : html`
${content}
`; - }, - )} -
- `, - )} - `; + return html` `; } #renderDefaultContent() { @@ -234,92 +136,6 @@ export class UmbUmbracoNewsDashboardElement extends UmbLitElement { margin-top: 0; margin-bottom: 0; } - - /* Grid */ - .cards { - --cols: 4; - --gap: var(--uui-size-space-4); - width: 100%; - display: grid; - grid-template-columns: repeat( - auto-fit, - minmax(calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols)), 1fr) - ); - gap: var(--gap); - } - - .cards + .cards { - margin-top: var(--uui-size-space-5); - } - - @container (max-width: 1200px) { - .cards { - grid-template-columns: repeat(auto-fit, minmax(2, 1fr)); - } - } - @container (max-width: 700px) { - .cards { - grid-template-columns: 1fr; - } - } - - /* Card */ - .card { - background: var(--uui-color-surface); - border-radius: var(--uui-border-radius, 8px); - box-shadow: var( - --uui-box-box-shadow, - var(--uui-shadow-depth-1, 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)) - ); - overflow: hidden; - display: flex; - flex-direction: column; - height: 100%; - } - - .card-img { - width: 100%; - object-fit: cover; - display: block; - } - - .card-img.placeholder { - height: 8px; - } - - .card-body { - display: flex; - flex-direction: column; - padding: var(--uui-size-space-5); - flex: 1 1 auto; - justify-content: space-between; - gap: var(--uui-size-space-3, 9px); - } - - .card-title { - margin: 0; - } - - .card-text > p { - margin: 0; - } - - .normal-priority { - display: block; - border: 1px solid var(--uui-color-divider); - border-radius: var(--uui-border-radius, 8px); - text-decoration: none; - color: inherit; - overflow: hidden; - - .card-body { - gap: 0; - } - } - - .card-actions { - align-self: end; - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/components/mfa-provider-default.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/components/mfa-provider-default.element.ts index 97bb775d0463..2ccb8ac8c2ec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/components/mfa-provider-default.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/components/mfa-provider-default.element.ts @@ -101,8 +101,8 @@ export class UmbMfaProviderDefaultElement extends UmbLitElement implements UmbMf ${this.localize.term('user_2faQrCodeAlt')}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/components/input-user-group/user-group-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/components/input-user-group/user-group-input.element.ts index f509aee05805..d86884dc9007 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/components/input-user-group/user-group-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/components/input-user-group/user-group-input.element.ts @@ -1,12 +1,24 @@ import { UMB_USER_GROUP_ENTITY_TYPE } from '../../entity.js'; import type { UmbUserGroupItemModel } from '../../repository/index.js'; import { UmbUserGroupPickerInputContext } from './user-group-input.context.js'; -import { css, html, customElement, property, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { + css, + customElement, + html, + ifDefined, + nothing, + property, + repeat, + state, +} from '@umbraco-cms/backoffice/external/lit'; +import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; -import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbRepositoryItemsStatus } from '@umbraco-cms/backoffice/repository'; + +import '@umbraco-cms/backoffice/entity-item'; @customElement('umb-user-group-input') export class UmbUserGroupInputElement extends UUIFormControlMixin(UmbLitElement, '') { @@ -75,6 +87,9 @@ export class UmbUserGroupInputElement extends UUIFormControlMixin(UmbLitElement, @state() private _items?: Array; + @state() + private _statuses?: Array; + #pickerContext = new UmbUserGroupPickerInputContext(this); @state() @@ -97,6 +112,7 @@ export class UmbUserGroupInputElement extends UUIFormControlMixin(UmbLitElement, this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')), '_observeSelection'); this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems), '_observerItems'); + this.observe(this.#pickerContext.statuses, (statuses) => (this._statuses = statuses), '_observeStatuses'); new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) .addAdditionalPath(UMB_USER_GROUP_ENTITY_TYPE) @@ -114,7 +130,15 @@ export class UmbUserGroupInputElement extends UUIFormControlMixin(UmbLitElement, override render() { return html` - ${this._items?.map((item) => this._renderItem(item))} + + ${this._statuses + ? repeat( + this._statuses, + (status) => status.unique, + (status) => this.#renderItem(status), + ) + : nothing} + x.unique === unique); + const isError = status.state.type === 'error'; + + // For error state, use umb-entity-item-ref + if (isError) { + return html` + + + this.#pickerContext.requestRemoveItem(unique)}> + + + `; + } + + // For successful items, use umb-user-group-ref + if (!item?.unique) return nothing; + const href = `${this._editUserGroupPath}edit/${unique}`; return html` ${item.icon ? html`` : nothing} this.#pickerContext.requestRemoveItem(item.unique)} + @click=${() => this.#pickerContext.requestRemoveItem(unique)} label=${this.localize.term('general_remove')}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/manifests.ts index 13c3dfb21346..ea9c5b869f43 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/manifests.ts @@ -1,4 +1,5 @@ import { UMB_USER_GROUP_WORKSPACE_ALIAS } from './constants.js'; +import { manifests as viewManifests } from './views/manifests.js'; import { UmbSubmitWorkspaceAction, UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ @@ -30,4 +31,5 @@ export const manifests: Array = [ }, ], }, + ...viewManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts index cbbad3824f57..08aeea40244f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts @@ -1,27 +1,18 @@ -import type { UmbUserGroupDetailModel } from '../../types.js'; import { UMB_USER_GROUP_ROOT_WORKSPACE_PATH } from '../../paths.js'; +import type { UmbUserGroupDetailModel } from '../../types.js'; import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from './user-group-workspace.context-token.js'; -import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui'; -import { css, html, nothing, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbInputSectionElement } from '@umbraco-cms/backoffice/section'; -import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; -import type { UmbInputLanguageElement } from '@umbraco-cms/backoffice/language'; -import { UMB_ICON_PICKER_MODAL } from '@umbraco-cms/backoffice/icon'; import type { UmbInputWithAliasElement } from '@umbraco-cms/backoffice/components'; - -import './components/user-group-entity-type-permission-groups.element.js'; +import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_ICON_PICKER_MODAL } from '@umbraco-cms/backoffice/icon'; +import { umbFocus, UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @customElement('umb-user-group-workspace-editor') export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { @state() private _isNew?: boolean = false; - @state() - private _unique?: UmbUserGroupDetailModel['unique']; - @state() private _name?: UmbUserGroupDetailModel['name']; @@ -34,41 +25,19 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { @state() private _icon?: UmbUserGroupDetailModel['icon']; - @state() - private _sections: UmbUserGroupDetailModel['sections'] = []; - - @state() - private _languages: UmbUserGroupDetailModel['languages'] = []; - - @state() - private _hasAccessToAllLanguages: UmbUserGroupDetailModel['hasAccessToAllLanguages'] = false; - - @state() - private _documentStartNode?: UmbUserGroupDetailModel['documentStartNode']; - - @state() - private _documentRootAccess: UmbUserGroupDetailModel['documentRootAccess'] = false; - - @state() - private _mediaStartNode?: UmbUserGroupDetailModel['mediaStartNode']; - - @state() - private _mediaRootAccess: UmbUserGroupDetailModel['mediaRootAccess'] = false; - #workspaceContext?: typeof UMB_USER_GROUP_WORKSPACE_CONTEXT.TYPE; constructor() { super(); - this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (instance) => { - this.#workspaceContext = instance; + this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (context) => { + this.#workspaceContext = context; this.#observeUserGroup(); }); } #observeUserGroup() { this.observe(this.#workspaceContext?.isNew, (value) => (this._isNew = value), '_observeIsNew'); - this.observe(this.#workspaceContext?.unique, (value) => (this._unique = value ?? undefined), '_observeUnique'); this.observe(this.#workspaceContext?.name, (value) => (this._name = value), '_observeName'); this.observe(this.#workspaceContext?.alias, (value) => (this._alias = value), '_observeAlias'); this.observe( @@ -77,102 +46,11 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { '_observeAliasCanBeChanged', ); this.observe(this.#workspaceContext?.icon, (value) => (this._icon = value), '_observeIcon'); - this.observe(this.#workspaceContext?.sections, (value) => (this._sections = value ?? []), '_observeSections'); - this.observe(this.#workspaceContext?.languages, (value) => (this._languages = value ?? []), '_observeLanguages'); - this.observe( - this.#workspaceContext?.hasAccessToAllLanguages, - (value) => (this._hasAccessToAllLanguages = value ?? false), - '_observeHasAccessToAllLanguages', - ); - - this.observe( - this.#workspaceContext?.documentRootAccess, - (value) => (this._documentRootAccess = value ?? false), - '_observeDocumentRootAccess', - ); - - this.observe( - this.#workspaceContext?.documentStartNode, - (value) => (this._documentStartNode = value), - '_observeDocumentStartNode', - ); - - this.observe( - this.#workspaceContext?.mediaRootAccess, - (value) => (this._mediaRootAccess = value ?? false), - '_observeMediaRootAccess', - ); - - this.observe( - this.#workspaceContext?.mediaStartNode, - (value) => (this._mediaStartNode = value), - '_observeMediaStartNode', - ); - } - - #onSectionsChange(event: UmbChangeEvent) { - event.stopPropagation(); - const target = event.target as UmbInputSectionElement; - // TODO make contexts method - this.#workspaceContext?.updateProperty('sections', target.selection); - } - - #onAllowAllLanguagesChange(event: UUIBooleanInputEvent) { - event.stopPropagation(); - const target = event.target; - // TODO make contexts method - this.#workspaceContext?.updateProperty('hasAccessToAllLanguages', target.checked); - } - - #onLanguagePermissionChange(event: UmbChangeEvent) { - event.stopPropagation(); - const target = event.target as UmbInputLanguageElement; - // TODO make contexts method - this.#workspaceContext?.updateProperty('languages', target.selection); - } - - #onAllowAllDocumentsChange(event: UUIBooleanInputEvent) { - event.stopPropagation(); - const target = event.target; - // TODO make contexts method - this.#workspaceContext?.updateProperty('documentRootAccess', target.checked); - this.#workspaceContext?.updateProperty('documentStartNode', null); } - #onDocumentStartNodeChange(event: CustomEvent) { - event.stopPropagation(); - // TODO: get back to this when documents have been decoupled from users. - // The event target is deliberately set to any to avoid an import cycle with documents. - const target = event.target as any; - const selected = target.selection?.[0]; - // TODO make contexts method - this.#workspaceContext?.updateProperty('documentStartNode', selected ? { unique: selected } : null); - } - - #onAllowAllMediaChange(event: UUIBooleanInputEvent) { - event.stopPropagation(); - const target = event.target; - // TODO make contexts method - this.#workspaceContext?.updateProperty('mediaRootAccess', target.checked); - this.#workspaceContext?.updateProperty('mediaStartNode', null); - } - - #onMediaStartNodeChange(event: CustomEvent) { - event.stopPropagation(); - // TODO: get back to this when media have been decoupled from users. - // The event target is deliberately set to any to avoid an import cycle with media. - const target = event.target as any; - const selected = target.selection?.[0]; - // TODO make contexts method - this.#workspaceContext?.updateProperty('mediaStartNode', selected ? { unique: selected } : null); - } - - override render() { - return html` - - ${this.#renderHeader()} ${this.#renderMain()} - - `; + #onNameAndAliasChange(event: InputEvent & { target: UmbInputWithAliasElement }) { + this.#workspaceContext?.updateProperty('name', event.target.value ?? ''); + this.#workspaceContext?.updateProperty('alias', event.target.alias ?? ''); } async #onIconClick() { @@ -193,9 +71,12 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { } } - #onNameAndAliasChange(event: InputEvent & { target: UmbInputWithAliasElement }) { - this.#workspaceContext?.updateProperty('name', event.target.value ?? ''); - this.#workspaceContext?.updateProperty('alias', event.target.alias ?? ''); + override render() { + return html` + + ${this.#renderHeader()} + + `; } #renderHeader() { @@ -219,118 +100,12 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { `; } - #renderMain() { - if (!this._unique) return nothing; - - return html` -
- - -
- - - - - - ${this.#renderLanguageAccess()} ${this.#renderDocumentAccess()} ${this.#renderMediaAccess()} -
- - ${this.#renderPermissionGroups()} -
-
- `; - } - - #renderLanguageAccess() { - return html` - -
- - - ${this._hasAccessToAllLanguages === false - ? html` - - ` - : nothing} -
-
- `; - } - - #renderDocumentAccess() { - return html` - -
- -
- - ${this._documentRootAccess === false - ? html` - - ` - : nothing} -
- `; - } - - #renderMediaAccess() { - return html` - -
- -
- - ${this._mediaRootAccess === false - ? html` - - ` - : nothing} -
- `; - } - - #renderPermissionGroups() { - return html` `; - } - static override styles = [ UmbTextStyles, css` :host { display: block; + width: 100%; height: 100%; } @@ -352,14 +127,6 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { flex: 1 1 auto; align-items: center; } - - #main { - padding: var(--uui-size-layout-1); - } - - uui-input { - width: 100%; - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/manifests.ts new file mode 100644 index 000000000000..91bacd1501cf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_USER_GROUP_WORKSPACE_ALIAS } from '../constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceView', + alias: 'Umb.WorkspaceView.UserGroup.Details', + name: 'User Group Details Workspace View', + element: () => import('./user-group-details-workspace-view.element.js'), + weight: 90, + meta: { + label: '#general_details', + pathname: 'details', + icon: 'edit', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_USER_GROUP_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts new file mode 100644 index 000000000000..1aaa43beb671 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts @@ -0,0 +1,275 @@ +import type { UmbUserGroupDetailModel } from '../../../types.js'; +import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.context-token.js'; +import type { UmbInputSectionElement } from '@umbraco-cms/backoffice/section'; +import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbInputLanguageElement } from '@umbraco-cms/backoffice/language'; +import { css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui'; + +import '../components/user-group-entity-type-permission-groups.element.js'; + +@customElement('umb-user-group-details-workspace-view') +export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement implements UmbWorkspaceViewElement { + @state() + private _unique?: UmbUserGroupDetailModel['unique']; + + @state() + private _sections: UmbUserGroupDetailModel['sections'] = []; + + @state() + private _languages: UmbUserGroupDetailModel['languages'] = []; + + @state() + private _hasAccessToAllLanguages: UmbUserGroupDetailModel['hasAccessToAllLanguages'] = false; + + @state() + private _documentStartNode?: UmbUserGroupDetailModel['documentStartNode']; + + @state() + private _documentRootAccess: UmbUserGroupDetailModel['documentRootAccess'] = false; + + @state() + private _mediaStartNode?: UmbUserGroupDetailModel['mediaStartNode']; + + @state() + private _mediaRootAccess: UmbUserGroupDetailModel['mediaRootAccess'] = false; + + #workspaceContext?: typeof UMB_USER_GROUP_WORKSPACE_CONTEXT.TYPE; + + constructor() { + super(); + + this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (instance) => { + this.#workspaceContext = instance; + this.#observeUserGroup(); + }); + } + + #observeUserGroup() { + this.observe(this.#workspaceContext?.unique, (value) => (this._unique = value ?? undefined), '_observeUnique'); + this.observe(this.#workspaceContext?.sections, (value) => (this._sections = value ?? []), '_observeSections'); + this.observe(this.#workspaceContext?.languages, (value) => (this._languages = value ?? []), '_observeLanguages'); + this.observe( + this.#workspaceContext?.hasAccessToAllLanguages, + (value) => (this._hasAccessToAllLanguages = value ?? false), + '_observeHasAccessToAllLanguages', + ); + + this.observe( + this.#workspaceContext?.documentRootAccess, + (value) => (this._documentRootAccess = value ?? false), + '_observeDocumentRootAccess', + ); + + this.observe( + this.#workspaceContext?.documentStartNode, + (value) => (this._documentStartNode = value), + '_observeDocumentStartNode', + ); + + this.observe( + this.#workspaceContext?.mediaRootAccess, + (value) => (this._mediaRootAccess = value ?? false), + '_observeMediaRootAccess', + ); + + this.observe( + this.#workspaceContext?.mediaStartNode, + (value) => (this._mediaStartNode = value), + '_observeMediaStartNode', + ); + } + + #onSectionsChange(event: UmbChangeEvent) { + event.stopPropagation(); + const target = event.target as UmbInputSectionElement; + // TODO make contexts method + this.#workspaceContext?.updateProperty('sections', target.selection); + } + + #onAllowAllLanguagesChange(event: UUIBooleanInputEvent) { + event.stopPropagation(); + const target = event.target; + // TODO make contexts method + this.#workspaceContext?.updateProperty('hasAccessToAllLanguages', target.checked); + } + + #onLanguagePermissionChange(event: UmbChangeEvent) { + event.stopPropagation(); + const target = event.target as UmbInputLanguageElement; + // TODO make contexts method + this.#workspaceContext?.updateProperty('languages', target.selection); + } + + #onAllowAllDocumentsChange(event: UUIBooleanInputEvent) { + event.stopPropagation(); + const target = event.target; + // TODO make contexts method + this.#workspaceContext?.updateProperty('documentRootAccess', target.checked); + this.#workspaceContext?.updateProperty('documentStartNode', null); + } + + #onDocumentStartNodeChange(event: CustomEvent) { + event.stopPropagation(); + // TODO: get back to this when documents have been decoupled from users. + // The event target is deliberately set to any to avoid an import cycle with documents. + const target = event.target as any; + const selected = target.selection?.[0]; + // TODO make contexts method + this.#workspaceContext?.updateProperty('documentStartNode', selected ? { unique: selected } : null); + } + + #onAllowAllMediaChange(event: UUIBooleanInputEvent) { + event.stopPropagation(); + const target = event.target; + // TODO make contexts method + this.#workspaceContext?.updateProperty('mediaRootAccess', target.checked); + this.#workspaceContext?.updateProperty('mediaStartNode', null); + } + + #onMediaStartNodeChange(event: CustomEvent) { + event.stopPropagation(); + // TODO: get back to this when media have been decoupled from users. + // The event target is deliberately set to any to avoid an import cycle with media. + const target = event.target as any; + const selected = target.selection?.[0]; + // TODO make contexts method + this.#workspaceContext?.updateProperty('mediaStartNode', selected ? { unique: selected } : null); + } + + override render() { + if (!this._unique) return nothing; + + return html` +
+ + +
+ + + + + + ${this.#renderLanguageAccess()} ${this.#renderDocumentAccess()} ${this.#renderMediaAccess()} +
+ + ${this.#renderPermissionGroups()} +
+
+ `; + } + + #renderLanguageAccess() { + return html` + +
+ + + ${this._hasAccessToAllLanguages === false + ? html` + + ` + : nothing} +
+
+ `; + } + + #renderDocumentAccess() { + return html` + +
+ +
+ + ${this._documentRootAccess === false + ? html` + + ` + : nothing} +
+ `; + } + + #renderMediaAccess() { + return html` + +
+ +
+ + ${this._mediaRootAccess === false + ? html` + + ` + : nothing} +
+ `; + } + + #renderPermissionGroups() { + return html` `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + height: 100%; + } + + #main { + padding: var(--uui-size-layout-1); + } + + uui-input { + width: 100%; + } + `, + ]; +} + +export { UmbUserGroupDetailsWorkspaceViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-group-details-workspace-view': UmbUserGroupDetailsWorkspaceViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-input/user-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-input/user-input.element.ts index f5621924100a..a1de877671c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-input/user-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-input/user-input.element.ts @@ -6,6 +6,7 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbRepositoryItemsStatus } from '@umbraco-cms/backoffice/repository'; // TODO: Shall we rename to 'umb-input-user'? [LK] @customElement('umb-user-input') @@ -92,6 +93,9 @@ export class UmbUserInputElement extends UUIFormControlMixin(UmbLitElement, '') @state() private _items?: Array; + @state() + private _statuses?: Array; + #pickerContext = new UmbUserPickerInputContext(this); constructor() { @@ -111,6 +115,7 @@ export class UmbUserInputElement extends UUIFormControlMixin(UmbLitElement, '') this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')), '_observeSelection'); this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems), '_observerItems'); + this.observe(this.#pickerContext.statuses, (statuses) => (this._statuses = statuses), '_observeStatuses'); } protected override getFormElement() { @@ -121,8 +126,8 @@ export class UmbUserInputElement extends UUIFormControlMixin(UmbLitElement, '') this.#pickerContext.openPicker({}); } - #removeItem(item: UmbUserItemModel) { - this.#pickerContext.requestRemoveItem(item.unique); + #removeItem(unique: string) { + this.#pickerContext.requestRemoveItem(unique); } override render() { @@ -141,24 +146,34 @@ export class UmbUserInputElement extends UUIFormControlMixin(UmbLitElement, '') } #renderItems() { - if (!this._items) return nothing; + if (!this._statuses) return nothing; return html` ${repeat( - this._items, - (item) => item.unique, - (item) => this.#renderItem(item), + this._statuses, + (status) => status.unique, + (status) => this.#renderItem(status), )} `; } - #renderItem(item: UmbUserItemModel) { - if (!item.unique) return nothing; + #renderItem(status: UmbRepositoryItemsStatus) { + const unique = status.unique; + const item = this._items?.find((x) => x.unique === unique); + const isError = status.state.type === 'error'; return html` - + - this.#removeItem(item)}> + this.#removeItem(unique)}> `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/manifests.ts index 850cee09f912..0dc237d29329 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/manifests.ts @@ -1,6 +1,7 @@ import { UMB_USER_ENTITY_TYPE } from '../../entity.js'; import { UMB_USER_WORKSPACE_ALIAS } from './constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS, UmbSubmitWorkspaceAction } from '@umbraco-cms/backoffice/workspace'; +import { manifests as viewManifests } from './views/manifests.js'; export const manifests: Array = [ { @@ -31,4 +32,5 @@ export const manifests: Array = [ }, ], }, + ...viewManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace-editor.element.ts index d0bf6b4cef35..ffc11e434da2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace-editor.element.ts @@ -1,93 +1,26 @@ -import type { UmbUserDetailModel } from '../../index.js'; import { UMB_USER_ROOT_WORKSPACE_PATH } from '../../paths.js'; -import type { UmbUserWorkspaceContext } from './user-workspace.context.js'; -import { UMB_USER_WORKSPACE_CONTEXT } from './user-workspace.context-token.js'; -import { css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -// import local components. Theses are not meant to be used outside of this component. -import './components/user-workspace-profile-settings/user-workspace-profile-settings.element.js'; -import './components/user-workspace-access/user-workspace-access.element.js'; -import './components/user-workspace-info/user-workspace-info.element.js'; -import './components/user-workspace-avatar/user-workspace-avatar.element.js'; -import './components/user-workspace-client-credentials/user-workspace-client-credentials.element.js'; - @customElement('umb-user-workspace-editor') export class UmbUserWorkspaceEditorElement extends UmbLitElement { - @state() - private _user?: UmbUserDetailModel; - - #workspaceContext?: UmbUserWorkspaceContext; - - constructor() { - super(); - - this.consumeContext(UMB_USER_WORKSPACE_CONTEXT, (context) => { - this.#workspaceContext = context; - this.#observeUser(); - }); - } - - #observeUser() { - if (!this.#workspaceContext) return; - this.observe(this.#workspaceContext.data, (user) => (this._user = user), 'umbUserObserver'); - } - override render() { return html` - + - ${this._user - ? html`
-
${this.#renderLeftColumn()}
-
${this.#renderRightColumn()}
-
` - : nothing}
`; } - #renderLeftColumn() { - return html` - - - - - - `; - } - - #renderRightColumn() { - return html` - - - - - - `; - } - static override styles = [ UmbTextStyles, css` :host { display: block; + width: 100%; height: 100%; } - - #main { - display: grid; - grid-template-columns: 1fr 350px; - gap: var(--uui-size-layout-1); - padding: var(--uui-size-layout-1); - } - - #left-column { - display: flex; - flex-direction: column; - gap: var(--uui-size-space-4); - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/manifests.ts new file mode 100644 index 000000000000..3e414288d594 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_USER_WORKSPACE_ALIAS } from '../constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceView', + alias: 'Umb.WorkspaceView.User.Details', + name: 'User Details Workspace View', + element: () => import('./user-details-workspace-view.element.js'), + weight: 90, + meta: { + label: '#general_details', + pathname: 'details', + icon: 'edit', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_USER_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/user-details-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/user-details-workspace-view.element.ts new file mode 100644 index 000000000000..292639178b33 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/views/user-details-workspace-view.element.ts @@ -0,0 +1,98 @@ +import { UMB_USER_WORKSPACE_CONTEXT } from '../user-workspace.context-token.js'; +import type { UmbUserWorkspaceContext } from '../user-workspace.context.js'; +import type { UmbUserDetailModel } from '../../../types.js'; +import { customElement, html, nothing, state, css } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +// import local components. Theses are not meant to be used outside of this component. +import '../components/user-workspace-profile-settings/user-workspace-profile-settings.element.js'; +import '../components/user-workspace-access/user-workspace-access.element.js'; +import '../components/user-workspace-info/user-workspace-info.element.js'; +import '../components/user-workspace-avatar/user-workspace-avatar.element.js'; +import '../components/user-workspace-client-credentials/user-workspace-client-credentials.element.js'; + +@customElement('umb-user-details-workspace-view') +export class UmbUserDetailsWorkspaceViewElement extends UmbLitElement implements UmbWorkspaceViewElement { + @state() + private _user?: UmbUserDetailModel; + + #workspaceContext?: UmbUserWorkspaceContext; + + constructor() { + super(); + + this.consumeContext(UMB_USER_WORKSPACE_CONTEXT, (context) => { + this.#workspaceContext = context; + this.#observeUser(); + }); + } + + #observeUser() { + if (!this.#workspaceContext) return; + this.observe(this.#workspaceContext.data, (user) => (this._user = user), 'umbUserObserver'); + } + + override render() { + return html` + ${this._user + ? html`
+
${this.#renderLeftColumn()}
+
${this.#renderRightColumn()}
+
` + : nothing} + `; + } + + #renderLeftColumn() { + return html` + + + + + + `; + } + + #renderRightColumn() { + return html` + + + + + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + height: 100%; + } + + #main { + display: grid; + grid-template-columns: 1fr 350px; + gap: var(--uui-size-layout-1); + padding: var(--uui-size-layout-1); + } + + #left-column { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-4); + } + `, + ]; +} + +export { UmbUserDetailsWorkspaceViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-details-workspace-view': UmbUserDetailsWorkspaceViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index e1928fbeec34..16c2d20becbd 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -114,6 +114,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/picker-input": ["./src/packages/core/picker-input/index.ts"], "@umbraco-cms/backoffice/picker-data-source": ["./src/packages/core/picker-data-source/index.ts"], "@umbraco-cms/backoffice/picker": ["./src/packages/core/picker/index.ts"], + "@umbraco-cms/backoffice/preview": ["./src/packages/preview/index.ts"], "@umbraco-cms/backoffice/property-action": ["./src/packages/core/property-action/index.ts"], "@umbraco-cms/backoffice/property-editor-data-source": [ "./src/packages/core/property-editor-data-source/index.ts" diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index 447f882911ea..3b59e7e12450 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -10,7 +10,7 @@ "@umbraco-cms/backoffice": "^16.2.0", "msw": "^2.11.3", "typescript": "^5.9.3", - "vite": "^7.1.9" + "vite": "^7.2.0" }, "engines": { "node": ">=22", @@ -529,7 +529,6 @@ "integrity": "sha512-LSBHP2/wTF1BnaccHGX1t+0Ss+2VJQxotrLz/0+LK2z8ocuyVZXOYhfBSd7FP8sK78MDJVDBYrPCsBUvNSlH1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@hey-api/codegen-core": "^0.2.0", "@hey-api/json-schema-ref-parser": "1.2.0", @@ -644,7 +643,8 @@ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@lit/reactive-element": { "version": "2.1.1", @@ -652,6 +652,7 @@ "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0" } @@ -704,7 +705,8 @@ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.47.1", @@ -1007,6 +1009,7 @@ "integrity": "sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1021,6 +1024,7 @@ "integrity": "sha512-zCce9PRuTNhadFir71luLo99HERDpGJ0EEflGm7RN8I1SnNi9gD5ooK42BOIQtejGCJqg3hTPZiYDJC2hXvckQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1035,6 +1039,7 @@ "integrity": "sha512-HHakuV4ckYCDOnBbne088FvCEP4YICw+wgPBz/V2dfpiFYQ4WzT0LPK9s7OFMCN+ROraoug+1ryN1Z1KdIgujQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1065,6 +1070,7 @@ "integrity": "sha512-GU9deB1A/Tr4FMPu71CvlcjGKwRhGYz60wQ8m4aM+ELZcVIcZRa1ebR8bExRIEWnvRztQuyRiCQzw2N0xQJ1QQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1079,6 +1085,7 @@ "integrity": "sha512-/TDDOwONl0qEUc4+B6V9NnWtSjz95eg7/8uCb8Y8iRbGvI9vT4/znRKofFxstvKmW4URu/H74/g0ywV57h0B+A==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1094,6 +1101,7 @@ "integrity": "sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1108,6 +1116,7 @@ "integrity": "sha512-JkDQU2ZYFOuT5mNYb8OiWGwD1HcjbtmX8tLNugQbToECmz9WvVPqJmn7V/q8VGpP81iEECz/IsyRmuf2kSD4uA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1123,6 +1132,7 @@ "integrity": "sha512-KOiMZc3PwJS3hR0nSq5d0TJi2jkNZkLZElcT6pCEnhRHzPH6dRMu9GM5Jj798ZRUy0T9UFcKJalFZaDxnmRnpg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1138,6 +1148,7 @@ "integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1152,6 +1163,7 @@ "integrity": "sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1166,6 +1178,7 @@ "integrity": "sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1181,6 +1194,7 @@ "integrity": "sha512-mT6baqOhs/NakgrAeDeed194E/ZJFGL692H0C7f1N7WDRaWxUu2oR0LrnRqSH5OyPjELkzu6nQnNy0+0tFGHHg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1211,6 +1225,7 @@ "integrity": "sha512-pOs6oU4LyGO89IrYE4jbE8ZYsPwMMIiKkYfXcfeD9NtpGNBnjeVXXF5I9ndY2ANrCAgC8k58C3/powDRf0T2yA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1244,6 +1259,7 @@ "integrity": "sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1258,6 +1274,7 @@ "integrity": "sha512-UHKNRxq6TBnXMGFSq91knD6QaHsyyOwLOsXMzupmKM5Su0s+CRXEjfav3qKlbb9e4m7D7S/a0aPm8nC9KIXNhQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1272,6 +1289,7 @@ "integrity": "sha512-UezvM9VDRAVJlX1tykgHWSD1g3MKfVMWWZ+Tg+PE4+kizOwoYkRWznVPgCAxjmyHajxpCKRXgqTZkOxjJ9Kjzg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1302,6 +1320,7 @@ "integrity": "sha512-CkoRH+pAi6MgdCh7K0cVZl4N2uR4pZdabXAnFSoLZRSg6imLvEUmWHfSi1dl3Z7JOvd3a4yZ4NxerQn5MWbJ7g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1407,6 +1426,7 @@ "integrity": "sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1436,6 +1456,7 @@ "integrity": "sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1560,7 +1581,8 @@ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", @@ -1568,6 +1590,7 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -1578,7 +1601,8 @@ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/statuses": { "version": "2.0.5", @@ -1592,7 +1616,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@umbraco-cms/backoffice": { "version": "16.2.0", @@ -1734,6 +1759,7 @@ "integrity": "sha512-O0807+bWVWV/rsFihFVKSOkg9wBtLXKCszE5+eZk2KmONm93BFhIAE35rp7eD6X2SuJMHwYzInIxMIMjHzdpUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button-group": "1.15.0" @@ -1745,6 +1771,7 @@ "integrity": "sha512-eEX83zwRN3tCiHScKcmkROWAfLu3TgFI9SntlbyxiuSSYfhJxWSZXOf6lVvQ/1CyvKq8XqSbBnN3VKXgcaKOpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1755,6 +1782,7 @@ "integrity": "sha512-CGYAFAHgNoQK2UTupP7pO0mwP6t9/Ms6WZ0gIC40a+kPjrGtaDWU52hiPsuXrUcR6WjJwZ5WRrJHOboRpdmM0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-avatar": "1.15.0", "@umbraco-ui/uui-base": "1.15.0" @@ -1766,6 +1794,7 @@ "integrity": "sha512-9aGmhRvey98kcR7wfsnno0BNftIRwJ0r2lCH6cNK2lhe69enYm0MWjp+4uutnlEWWskTLEI344BOqmqOHH1NPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1776,6 +1805,7 @@ "integrity": "sha512-0vtKmjzUOn/tIUHNrsx7aZpy3eq9aRKqV9kkJTrhH92S4WcMy+cOB1iw9t3Fe3xlBPuL3JpszwuxMTIuIqJTgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "lit": ">=2.8.0" } @@ -1786,6 +1816,7 @@ "integrity": "sha512-LkYX+p44mFVdvlZSliP5ClMcyHoOIVLWI3WVkaMLQdNi2LO9bbfaTneLzu4ruW6v0iF+kLsznr3bl73VHk7oEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1796,6 +1827,7 @@ "integrity": "sha512-MhSNcKsVNymD/yt3NFXujuaQmAqMqj5S+CBoDHEk88H7Id9NMw9RStZFJ37mI2CxHWkeHDotNVgOhSBiHJNJnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-css": "1.15.0" @@ -1807,6 +1839,7 @@ "integrity": "sha512-TaUY+hNB0VIwv9SBi9fDjIFRtrmmkcT7hlhLCJLUVfQ7jJlGLPISAAdypSplNeCPthYvP1cJQ9m28OzscXHZxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1817,6 +1850,7 @@ "integrity": "sha512-3Oaqj6Yta/Q/Ndme20YA1XbHdBBL71iNhpqREfTHli2YV4TEcgIiNy0s2op2oPhKjIEQPEfitU2BrruYEEWa7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon-registry-essential": "1.15.0" @@ -1828,6 +1862,7 @@ "integrity": "sha512-MAaJzpwVnlyGJNvLv6qIwrYsI5SaXXiVKgVi47I8+x//QmnArmetCN04766gGzmAb0m2uuC3ht0BXMDv05pxvw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0" @@ -1839,6 +1874,7 @@ "integrity": "sha512-YPEnubKNbKmw04eWRH24/3Uxu+zhtLPeJoaT6ykPCyjr/EKc82rSTvn8fwQuP41UokQrXOac2pKn7SncyoST1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1849,6 +1885,7 @@ "integrity": "sha512-nLJZ6P5eK1rYgqjP5zCxbZp8g4WJ23RnUZQ49o7QpU/7zoPOK72/fuM3Ky00Iapixm/kAD6dYHO/P+GtNz8/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1859,6 +1896,7 @@ "integrity": "sha512-pNjpk2iIdSsmTtDdBsWaEr8JX0RcWbl8yKGaqLvo/S7d3bly5z+FjcsgGnX1i1GHo7dqmgVJfbdvN9V1jgn+FA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-checkbox": "1.15.0" @@ -1870,6 +1908,7 @@ "integrity": "sha512-cWag+D0XrogYZesAN8NMPQCCuU7L7uZ4Xz8dmirKQk1gjMrFDC4vYPZRQ/5O3ETTFupfDipVKimgRDsmarbLSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-card": "1.15.0" @@ -1881,6 +1920,7 @@ "integrity": "sha512-DZ6JYNvGb5wVkhhLShENMm+Y6kTpz37YrApQTJVUUgPXhIABO2CDCnqgpH5tkQX73s9jjVB3Ca7SeivuYv8G9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-card": "1.15.0", @@ -1893,6 +1933,7 @@ "integrity": "sha512-EzYebWCzR0wHY902NmAcTRSVSscYac3QntCz+xwSulrhzfy4copeOd1qE+Lz7FjHs+ho0IBPZol8sF4W6rK8FQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-card": "1.15.0", @@ -1906,6 +1947,7 @@ "integrity": "sha512-oo7gCs3RGJ4ujFs+LpG9I1DS/XSNkz9gaqvp4BkaR0kBXzw5f2SLLGhA9S3M6M+OPlsXuuJNKlTV1tn2+LF6Ng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-avatar": "1.15.0", "@umbraco-ui/uui-base": "1.15.0", @@ -1918,6 +1960,7 @@ "integrity": "sha512-cnKP5GeaI028hGabVCki1kPqAVSekFeP7QEwu7lncA+dcX8uvg+ffV6kW9FV0emOhI5Tmxvh8o+UDKlLs28q3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1928,6 +1971,7 @@ "integrity": "sha512-vPkgrFAPDMvJdJTADIWNj48D8gJWD3dBotucUghg/wHhvJv8h/2MvnwMUNnnSB1REHbanl7hJBVvKcNkoil0gA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-boolean-input": "1.15.0", @@ -1940,6 +1984,7 @@ "integrity": "sha512-k6u//b+s6UYmzKYMizIf2MRGD4kFy1qWdSk1GnIeDdiQxABJuBZEtkACIe66j+lxnonFvZ/assbLbhRiu15ZCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "colord": "^2.9.3" @@ -1951,6 +1996,7 @@ "integrity": "sha512-I4KGyzZZazjeifcavHp7qnMbP0Jh0dM+gzZhV+YtdPR2JT0o7y6stkbY0f+dOln0K6Bu6BQLV0HLHl/1f/1NDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-popover-container": "1.15.0", @@ -1963,6 +2009,7 @@ "integrity": "sha512-lpT9kapypGkTelG9COSk169VKs0MSiKweX8menDDn0p6I4RfKQBy0N27HecCcf1RqPsCnTbP3lPr5DJy00KdzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -1973,6 +2020,7 @@ "integrity": "sha512-1AI0QMr046fKc8xZ4aBO7FDwvggsS9plIpY0W4AGrqQxqGUR2u/mTU49+8xMtboaFOen5RQpJ65DN9hAgeNZ+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon-registry-essential": "1.15.0", @@ -1985,6 +2033,7 @@ "integrity": "sha512-UzlgWdsVHyCM/znFThrfA4A/S/K/R9Nc2KyRYiyy2xgBoP7x2vJ5Rn4mnR02W4bhI3gNgCJ2fqhmyiW4dxyk0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-color-swatch": "1.15.0" @@ -1996,6 +2045,7 @@ "integrity": "sha512-CKslvVRCKCReMr/ZZh4wc3TKJNvFjKVm/hSIvFqCIoJuSKfC4XuLU9SK9FL1s42NUAUmccSD3hATZJZ9VXqY+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0", @@ -2012,6 +2062,7 @@ "integrity": "sha512-e8IhqU9AC5pOqXuzPzI+BDsW97Ac0u1GU/5MIJqRcBZ+ZttPcH4fsm4u8siCHbK1khCG0Vzo7HiKPZ0IuuOslg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2033,6 +2084,7 @@ "integrity": "sha512-iVsrVVnvBrCCT9uJhyBE7b1kXwWUUKDmimhs/TyF1SFjxWP/U0Z99QqqI1pawdad+BuK3oVCmxYOdaReWDQXkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-css": "1.15.0" @@ -2044,6 +2096,7 @@ "integrity": "sha512-JdDRIzSGGDnvVqXSIhc+5rDXMdYMO+Hd7s2hqLp+iRSn8IHISN/qT1nfFVO9LMbLdcApanl3JJ4Rru9LN4Q3HA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2054,6 +2107,7 @@ "integrity": "sha512-MhJRkVdDQWKEBvemNRD4bZCuIS0JUll1nNoPK7scA+e6vDmbv25vqPHNXGE/sIpVkChY/L+v+twokzlHn57XMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-symbol-file-dropzone": "1.15.0" @@ -2065,6 +2119,7 @@ "integrity": "sha512-AHKIdYLC0ga4Wgr68xtW/gG3NDqn+QhD2aus0l2n4lBoq6OAQ5aZiPwD9i1fCD7dgyjKQ6Ov9PJSaqRYQkOlNA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-symbol-file": "1.15.0", @@ -2078,6 +2133,7 @@ "integrity": "sha512-4u9ZryfVBunpb0IL0+TevytrISA6S1+AajiK/PUk0JMJfqMuQMjmpnNPdtYRNVgFFIcQFQKipjT/mrHbDVawxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2088,6 +2144,7 @@ "integrity": "sha512-fiWGeQpREnl6k+6VNHz9ixNdEmOoFNm7qsgdIYJ1jCDXBGME1mjxJOr2Eq7UWJuzQM8BeyQEcXq5SVIOv21PRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-form-validation-message": "1.15.0" @@ -2099,6 +2156,7 @@ "integrity": "sha512-RYfwmjPkY0KumjaalLW8gkasW25Mj87YFAzJn7mAYiZigURape9RqGpvrBfwcMmGj3W2/uVuHxpAHrvweQOt4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2109,6 +2167,7 @@ "integrity": "sha512-e8/W6gu6kwrodH0f0U70LR5rHQhiZGq3NqLqikAQ1rvmwitXUqtkVXIhkGxSf7M6yPhpmoi2qEEZDQH9cvCE5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2119,6 +2178,7 @@ "integrity": "sha512-nIdzCqoABeRVG6jW045ok649MiAhm5zPdfuMKc1a+TNw9xkKj+vnA1YcjaBN502+AekMhhwnqgj9mLL+mC2VPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon": "1.15.0" @@ -2130,6 +2190,7 @@ "integrity": "sha512-llHFVMlV3Uyg2fHiNt1qfDgRhLthD37uQD2FzlQb0GEYjp+4dE8Jyc/eZW2mqABPweUJACVwbrwBUVrCeQJ1OQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon-registry": "1.15.0" @@ -2141,6 +2202,7 @@ "integrity": "sha512-vPc4I/kkQM9RWfHI0F/OQhoTu+KefplbQp0JEQ4gfr6MwxIm6bBTEuw8T5K9t1DQs8EZ7yeLEsSh65FDPepRtg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2151,6 +2213,7 @@ "integrity": "sha512-VVn2FMsflvEWd6fOX0HQ3JaUh7haznqSqCLTSTOduh/H3jE+dVYCW6YC5uTsxArmOwsSBYSfBQNetW23eJim3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-action-bar": "1.15.0", "@umbraco-ui/uui-base": "1.15.0", @@ -2166,6 +2229,7 @@ "integrity": "sha512-AFyVYNeExHXe10b3/5/BLZOmMKyMxzftsO0HKbaQQuxrxL2SCHsQJRUpxSY+/0vAl2JbNdmrk0HTsP1O4Y9zig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0", @@ -2179,6 +2243,7 @@ "integrity": "sha512-Pe8lNdHz/6IfbQyWEi0o+pKJ6/zunQ2b8HARCU0a9HFXRDk+XsAuBsn79zQXZl5MvseAUQrnouLwPHpdtMbeMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon-registry-essential": "1.15.0", @@ -2191,6 +2256,7 @@ "integrity": "sha512-8Q/G5Lg6949BbMHQ1BhZ9UpoJjOQ19w1tl2y0d/rP3w/mKnTQaBSf+MQmA/6kQ/Unb2wHXJANr4pAGpUklOg6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2201,6 +2267,7 @@ "integrity": "sha512-fnmRl+RGUROERvt+Jw0WiW3Btlddg0Xka6F+gR95gy5gr/v8s34uf1/bbPD3hWUXZPukLmxeMjbzyuqMrO8rpQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2211,6 +2278,7 @@ "integrity": "sha512-HQ2zCp2kz45GWQ3wV153ytuYD2KcdeAA5RRUVrN0Zn3GQB3wfG7xMkQQNRAOWMUdnfqmdQHeK+COO7NaET3VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2221,6 +2289,7 @@ "integrity": "sha512-4eMeerunFc5yZsJIwpHADn8oGcu0Nn36oyKbSd0qC0mNmmN2i8UOF9w4O+lndd2L0Mhv23FGvBRo7mb5EAvWlg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2231,6 +2300,7 @@ "integrity": "sha512-4rG8UHvyS2qvsjQYEmYjKX01SRwfk60oH8SSSx8r3z2BM62dCOa+4SBhLxqiBciC/u8FtN8X20MIGE0+eMdtoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2241,6 +2311,7 @@ "integrity": "sha512-BOebCMB/p4TaK4kJYrYgimC6SSGBHN4y1MytK3tyvObbuj3gVqkbwHW5CZrhK4jMaywRgGq96OsuaGfc52HFog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-loader-bar": "1.15.0", @@ -2253,6 +2324,7 @@ "integrity": "sha512-EDz1Qx+mTXNvOu565IculPCyuuFHwBon2wYnfWDBMoHJ5+P54jBHzg2U/8fUVse7xKPvU21hF4JneAvycNIiGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2263,6 +2335,7 @@ "integrity": "sha512-sPVs1bApKupNd2JcSMwFS1060Y++Fti1ybJrafcLh1+h4IjmLDIRTHTTL8C+kei5G2Oi3+Z7vGpLu7lrTAmRlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0", @@ -2275,6 +2348,7 @@ "integrity": "sha512-VCHVvO0fd5eL5UvB/RPL/K68UhOgsIpuyr+aXLblaYT/6at2LNosUxR4eRW2r0WOQzOiyE+Nu69pExBKyfT8bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2285,6 +2359,7 @@ "integrity": "sha512-54M4G0ru8j5ltPAdDGIxogdmos33hxeQeusI/uMFxo2yqHHyMHRi95vvCdcwFmGlEdFd2rsnxZKfNMUKM99GKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2295,6 +2370,7 @@ "integrity": "sha512-vtGUwHaG4EDLQERkwym51OasoWLj30LQLhcCCYXDJtTL1dG2nIKScEtlSUiVh5qRsI+V+kaBYPGD4TFD5o43tQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2305,6 +2381,7 @@ "integrity": "sha512-5TUF/iWUzbVXvBs9Z273q6s9yLbns8itTiFHCITw5w5fZzDn8R6O5hrOW7tV79kCxAnBSOAVP8v1JhGTwXw19Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2315,6 +2392,7 @@ "integrity": "sha512-HMHVdBoB1O39rojofezee2aXGv6CMn7dUFvNefdF9HxmNrIcpFBYXSL8aBt5QJeziFQMwbCtqyY21aUag0nzfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2325,6 +2403,7 @@ "integrity": "sha512-w7FZIe5mtsgvsf6hOH5mHKDBzg9Rd/+viyk/xNVs1NeZBn1nWEIHZs0R7YMpv+QxulklhAOpBcbGoUTB8xE+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2335,6 +2414,7 @@ "integrity": "sha512-UT65bpUmRlEgVuvn2RlTZ5l2WDF82jH1t8g+6HV6OJctpqTKlOfPkQmd6AluESPhHFEwwTydS/M7x+X3Adkdtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2345,6 +2425,7 @@ "integrity": "sha512-ybDqIt1cXd7AiZLZsDrSHCMp2zM8I+0lmN599b3NROjm59SZXIvpbY1TS1gJ45htgsc18x2y+S4laInYu2dGUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-icon": "1.15.0", @@ -2357,6 +2438,7 @@ "integrity": "sha512-59s16558ySCX7b9IT/Sorq0fdFeCRENSTa7DIkQUrvVPaFWqKFz9jCYFEqDnH11jZHGsNiYh5YCmWlF/VNbwJQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2368,6 +2450,7 @@ "integrity": "sha512-AWPZPkFGcAkRx4j6JnOi2r3EJxnvZUXfhOWNyWB2/dFRckatPH56+lVkqV+fRC+81emKBSQWkx2NphFzLEMr0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2379,6 +2462,7 @@ "integrity": "sha512-knIIbbfoWtepOvyC54dCo3xF0Vuap6i5uMQPd+wITCmg56a+yiJFuke+dyzatOIeXzgspLgFUngwQZEj5mJnWA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2390,6 +2474,7 @@ "integrity": "sha512-pXvL523m2JR3P8OO+E1AE4YAaYhJLc519CtjNXSuctNIk1yWvwxBu8VozLIQV+xrOXGz+SiXwDkoaRPwjTQKtg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2401,6 +2486,7 @@ "integrity": "sha512-bQWfZPKJyAf2O/YvOD6fVSSpKaYZMBsrEGT+ydLPv3BNJroYHS8+NEbulZxExWztNApTcs6Vo5T19AUz+vsnLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2412,6 +2498,7 @@ "integrity": "sha512-4GpRzhGedMwjqW1Wk7AvgakNCc6S1edYpHWeO6cfmryIm0hvnCfkU132lzLmB+Ag2QIOI8p4Ak0OQHYWd+XZHw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-ref-node": "1.15.0" @@ -2423,6 +2510,7 @@ "integrity": "sha512-L4qM6GPDqu0/9B2OVb3EljZT3zYxbwp6uOz1nfVYpGAWBxd6INOtNbn8WYdZLq6qqa8NR6qK+su2554nInvQGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2433,6 +2521,7 @@ "integrity": "sha512-yRx+TlXBB05jM8ShThRooFgCS5nSN7eAAnpttZgBWqY3sccIVy2Knbkz3kXLJE6ld+bO5nUXhsZBZ34MEHkiog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2443,6 +2532,7 @@ "integrity": "sha512-+OAzOutyUB2WCI+e5aFRoUNsFFuc/hUXnpIjx4P1moOYiggc/NxjaTHz5mxbmkC11yyS+0vpl8lVSZglkLCH5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2453,6 +2543,7 @@ "integrity": "sha512-6y9rpFfhtuWMnaAamlzrB5Q12dsZ8dprmQaGtKr+g97PTNRPC3/dc5sdROam8VMDAhL9MkfBAZBoS6yAoJsPcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2463,6 +2554,7 @@ "integrity": "sha512-F0BueWxu6J5P7xyzkp1c/eFZJjStsw65hB3bNEmWBOqkm/jbBKg9+Xs99tov+VwCHYOt8T+DuEDkkKmdxVAxyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2473,6 +2565,7 @@ "integrity": "sha512-D5DottbukIFxL+YTVEMujHPdqB8Hhw02TKpegfDQb8UGSPC5pCQw4O212TSuyTalKb598niNmCzcjEG5TWNqww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2483,6 +2576,7 @@ "integrity": "sha512-JLcEVnJqv2LL8VtscPCtPKda9ywWzV4vd0XODHLE3iI1cgHeNwMBhxqgkah0ULuw5w2Hrq8gwQ7/DuPHMSIFxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2493,6 +2587,7 @@ "integrity": "sha512-CPsr1K5IkdxBgID+xoIcgbumm/z0q+Z/1NPxTO40EL7kx3KOLQ8vwLdOTSW1cTj90JFA9+XuRtOpmMEY0XjICg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2503,6 +2598,7 @@ "integrity": "sha512-5QyDNFjiBeuPgalT9zwPMP220zJUHPpbPvCohWCFLn/2JJsa6IjSMtsAcqxI154ZJ9vYX7vYiYUn8tJTY8CHpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2513,6 +2609,7 @@ "integrity": "sha512-BQq7BwZ7nCcgKE5tMhG6OVYTrrMEIXpx8kQKec/ULgVfs0/Ws6qeH9u4rGVK/yHU8gecd6DSeUczjjq2iS4djA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2523,6 +2620,7 @@ "integrity": "sha512-5Akw8T0SV2OrwvPk1JSeFr1clvHE4N0DwceSU9bn9f6gLIGGRxvniJAclQDRI/Woe3hm8waMy6cC2fXfSdc6lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2533,6 +2631,7 @@ "integrity": "sha512-AnPp0QJeI70ucX8ludr3qaFmlxjKZUarX10DI8ieIB8VJiQZo0TjoPcPdSGmZupaPBLiszlpb3rKzGkZhXEIHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2543,6 +2642,7 @@ "integrity": "sha512-oS0eA5Z8+s+5o2ks3WCED5VGP8AunRLyuB2y7kVdRUfhCfck7B9v83zNfxPVoGoVsTDLtAQM1S4P8SHwNRmk7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0", @@ -2556,6 +2656,7 @@ "integrity": "sha512-PgyZvAiOZmXmiRW4UhfD6Tybx3ft755aKAVqT8ELpskLSvVr1oz4uTI6+QxoeQ1AkrHovenvIdBX+Iwi91SheQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2566,6 +2667,7 @@ "integrity": "sha512-tk/RVzCxs+KPSJ+qH2Xlr9RYxcdrSNulDKk5sBCQR0A9nwOffa15SGreSMKWgq+gYOVYChHBg/WxLWLq3d7Rlg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2576,6 +2678,7 @@ "integrity": "sha512-nz+snpjPFE+ftH5R/ekgZYy9ofGAf51yQYjWCtBwkrQ6D1dIBYA6kynZFdqIefrRwJJ5zHpe25BcS/AyRPc/9Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-button": "1.15.0", @@ -2590,6 +2693,7 @@ "integrity": "sha512-fd5d0DU/x2+u15rP0wrjw29M0oqsDFmnAfbPEdgQoPV+hvq9/SLhxJtzx10ZSNXoyuO9sTK50Q7nYsqOvGCzqg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-toast-notification": "1.15.0" @@ -2601,6 +2705,7 @@ "integrity": "sha512-uf/e/dVN6kqX76vcawiQM3w1nMHa8A+ZTtNwxtmAZi8bNPwjXLNaqKfeSp2thTByCIzFz7imnft56QtYLbksOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-css": "1.15.0" @@ -2612,6 +2717,7 @@ "integrity": "sha512-WLooENcxuAobbXxN1W2uKGh/cN9k0f3cRmDDtCZdgjeheGlBYWatkc5HQte7zchXHUi0xTrsvBCBa9CsLKN/3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0", "@umbraco-ui/uui-boolean-input": "1.15.0" @@ -2623,6 +2729,7 @@ "integrity": "sha512-vn3dbpYGekAqG944Vkwd0ILQRtTaZtL1BVdsge2UsU8sOsEKwv5YzQal4b+o8yu8nb4vZbWHZ2zRmnpnPgPmjg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@umbraco-ui/uui-base": "1.15.0" } @@ -2848,7 +2955,8 @@ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/commander": { "version": "13.0.0", @@ -2892,7 +3000,8 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/default-browser": { "version": "5.2.1", @@ -3007,6 +3116,7 @@ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.12" }, @@ -3071,6 +3181,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3282,6 +3393,7 @@ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -3291,7 +3403,8 @@ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lit": { "version": "3.3.1", @@ -3312,6 +3425,7 @@ "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", @@ -3324,6 +3438,7 @@ "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -3341,6 +3456,7 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -3372,7 +3488,8 @@ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/minimist": { "version": "1.2.8", @@ -3532,7 +3649,8 @@ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/outvariant": { "version": "1.4.3", @@ -3575,7 +3693,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3630,6 +3747,7 @@ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-transform": "^1.0.0" } @@ -3640,6 +3758,7 @@ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0" } @@ -3650,6 +3769,7 @@ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -3662,6 +3782,7 @@ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -3674,6 +3795,7 @@ "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -3687,6 +3809,7 @@ "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -3700,6 +3823,7 @@ "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -3711,6 +3835,7 @@ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -3722,6 +3847,7 @@ "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", @@ -3734,6 +3860,7 @@ "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -3758,6 +3885,7 @@ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.25.0" } @@ -3768,6 +3896,7 @@ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -3793,6 +3922,7 @@ "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.25.0", @@ -3807,6 +3937,7 @@ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" @@ -3823,6 +3954,7 @@ "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -3846,6 +3978,7 @@ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -3937,7 +4070,8 @@ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-applescript": { "version": "7.0.0", @@ -4116,7 +4250,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-fest": { "version": "4.34.1", @@ -4137,7 +4272,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4151,7 +4285,8 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/uglify-js": { "version": "3.19.3", @@ -4193,9 +4328,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", + "integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==", "dev": true, "license": "MIT", "dependencies": { @@ -4272,7 +4407,8 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/wordwrap": { "version": "1.0.0", diff --git a/src/Umbraco.Web.UI.Login/package.json b/src/Umbraco.Web.UI.Login/package.json index 887137edd05a..47809d0d1496 100644 --- a/src/Umbraco.Web.UI.Login/package.json +++ b/src/Umbraco.Web.UI.Login/package.json @@ -1,28 +1,28 @@ { - "name": "login", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "watch": "tsc && vite build --watch", - "preview": "vite preview", - "generate:server-api": "openapi-ts" - }, - "engines": { - "node": ">=22", - "npm": ">=10.9" - }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.85.0", - "@umbraco-cms/backoffice": "^16.2.0", - "msw": "^2.11.3", - "typescript": "^5.9.3", - "vite": "^7.1.9" - }, - "msw": { - "workerDirectory": [ - "public" - ] - } + "name": "login", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "watch": "tsc && vite build --watch", + "preview": "vite preview", + "generate:server-api": "openapi-ts" + }, + "engines": { + "node": ">=22", + "npm": ">=10.9" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.85.0", + "@umbraco-cms/backoffice": "^16.2.0", + "msw": "^2.11.3", + "typescript": "^5.9.3", + "vite": "^7.2.0" + }, + "msw": { + "workerDirectory": [ + "public" + ] + } } diff --git a/src/Umbraco.Web.UI.Login/src/assets/eye-closed.svg b/src/Umbraco.Web.UI.Login/src/assets/eye-closed.svg new file mode 100644 index 000000000000..3a29b4297436 --- /dev/null +++ b/src/Umbraco.Web.UI.Login/src/assets/eye-closed.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Login/src/assets/eye-open.svg b/src/Umbraco.Web.UI.Login/src/assets/eye-open.svg new file mode 100644 index 000000000000..5f28e8e03ff5 --- /dev/null +++ b/src/Umbraco.Web.UI.Login/src/assets/eye-open.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Login/src/auth-styles.css b/src/Umbraco.Web.UI.Login/src/auth-styles.css index f63dfa92e60d..76c38bc09112 100644 --- a/src/Umbraco.Web.UI.Login/src/auth-styles.css +++ b/src/Umbraco.Web.UI.Login/src/auth-styles.css @@ -1,44 +1,94 @@ -#umb-login-form input { - width: 100%; - height: var(--input-height); - box-sizing: border-box; - display: block; - border: 1px solid var(--uui-color-border); - border-radius: var(--uui-border-radius); - background-color: var(--uui-color-surface); - padding: var(--uui-size-1, 3px) var(--uui-size-space-4, 9px); +.errormessage { + color: var(--uui-color-invalid-standalone); + display: none; + margin-top: var(--uui-size-1); } -#umb-login-form uui-form-layout-item { - margin-top: var(--uui-size-space-4); - margin-bottom: var(--uui-size-space-4); +.errormessage.active { + display: block; } -#umb-login-form input:focus-within { - border-color: var(--uui-input-border-color-focus, var(--uui-color-border-emphasis, #a1a1a1)); - outline: calc(2px * var(--uui-show-focus-outline, 1)) solid var(--uui-color-focus); +uui-form-layout-item { + margin-top: var(--uui-size-space-4); + margin-bottom: var(--uui-size-space-4); } -#umb-login-form input:hover:not(:focus-within) { - border-color: var(--uui-input-border-color-hover, var(--uui-color-border-standalone, #c2c2c2)); +#username-input { + width: 100%; + height: var(--input-height); + box-sizing: border-box; + display: block; + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-border-radius); + background-color: var(--uui-color-surface); + padding: var(--uui-size-1, 3px) var(--uui-size-space-4, 9px); } -#umb-login-form input::-ms-reveal { - display: none; +#username-input:focus-within { + border-color: var(--uui-input-border-color-focus, var(--uui-color-border-emphasis, #a1a1a1)); + outline: calc(2px * var(--uui-show-focus-outline, 1)) solid var(--uui-color-focus); } -#umb-login-form input span { - position: absolute; - right: 1px; - top: 50%; - transform: translateY(-50%); - z-index: 100; +#username-input:hover:not(:focus-within) { + border-color: var(--uui-input-border-color-hover, var(--uui-color-border-standalone, #c2c2c2)); } -#umb-login-form input span svg { - background-color: white; - display: block; - padding: .2em; - width: 1.3em; - height: 1.3em; +#password-show-toggle { + color: var(--uui-color-default-standalone); + display: inline-flex; + justify-content: center; + align-items: center; + vertical-align: middle; + min-width: 24px; + min-height: 24px; + border-color: transparent; + background-color: transparent; + padding: 0; + transition-property: color; + transition-duration: 0.1s; + transition-timing-function: linear; +} + +#password-show-toggle:hover { + color: var(--uui-color-default-emphasis); + cursor: pointer; +} + +#password-input-span { + display: inline-flex; + width: 100%; + align-items: center; + flex-wrap: nowrap; + position: relative; + vertical-align: middle; + column-gap: 0; + height: var(--input-height); + box-sizing: border-box; + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-border-radius); + background-color: var(--uui-color-surface); + padding: var(--uui-size-1, 3px) var(--uui-size-space-4, 9px); +} + +#password-input { + flex-grow: 1; + align-self: stretch; + min-width: 0; + display: block; + border-style: none; + padding: 0; + outline-style: none; +} + +#password-input-span:focus-within { + border-color: var(--uui-input-border-color-focus, var(--uui-color-border-emphasis, #a1a1a1)); + outline: calc(2px * var(--uui-show-focus-outline, 1)) solid var(--uui-color-focus); +} + +#password-input-span:hover:not(:focus-within) { + border-color: var(--uui-input-border-color-hover, var(--uui-color-border-standalone, #c2c2c2)); +} + +#password-input::-ms-reveal { + display: none; } diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index d2fb5e1bc07a..a3ba757cb025 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -3,12 +3,16 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { InputType, UUIFormLayoutItemElement } from '@umbraco-cms/backoffice/external/uui'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import { UMB_AUTH_CONTEXT, UmbAuthContext } from './contexts'; -import { UmbSlimBackofficeController } from './controllers'; +import { UMB_AUTH_CONTEXT, UmbAuthContext } from './contexts/index.js'; +import { UmbSlimBackofficeController } from './controllers/index.js'; // We import the authStyles here so that we can inline it in the shadow DOM that is created outside of the UmbAuthElement. import authStyles from './auth-styles.css?inline'; +// Import the SVG files +import svgEyeOpen from './assets/eye-open.svg?raw'; +import svgEyeClosed from './assets/eye-closed.svg?raw'; + // Import the main bundle import { extensions } from './umbraco-package.js'; @@ -17,6 +21,7 @@ const createInput = (opts: { type: InputType; name: string; autocomplete: AutoFill; + errorId: string; inputmode: string; autofocus?: boolean; }) => { @@ -27,7 +32,10 @@ const createInput = (opts: { input.id = opts.id; input.required = true; input.inputMode = opts.inputmode; + input.setAttribute('aria-errormessage', opts.errorId); input.autofocus = opts.autofocus || false; + input.className = 'input'; + return input; }; @@ -42,26 +50,116 @@ const createLabel = (opts: { forId: string; localizeAlias: string; localizeFallb return label; }; -const createFormLayoutItem = (label: HTMLLabelElement, input: HTMLInputElement) => { +const createValidationMessage = (errorId: string) => { + const validationElement = document.createElement('div'); + validationElement.className = 'errormessage'; + validationElement.id = errorId; + validationElement.role = 'alert'; + return validationElement; +}; + +const createShowPasswordToggleButton = (opts: { + id: string; + name: string; + ariaLabelShowPassword: string; + ariaLabelHidePassword: string; +}) => { + const button = document.createElement('button'); + button.id = opts.id; + button.ariaLabel = opts.ariaLabelShowPassword; + button.name = opts.name; + button.type = 'button'; + + button.innerHTML = svgEyeOpen; + + button.onclick = () => { + const passwordInput = document.getElementById('password-input') as HTMLInputElement; + + if (passwordInput.type === 'password') { + passwordInput.type = 'text'; + button.ariaLabel = opts.ariaLabelHidePassword; + button.innerHTML = svgEyeClosed; + } else { + passwordInput.type = 'password'; + button.ariaLabel = opts.ariaLabelShowPassword; + button.innerHTML = svgEyeOpen; + } + + passwordInput.focus(); + }; + + return button; +}; + +const createShowPasswordToggleItem = (button: HTMLButtonElement) => { + const span = document.createElement('span'); + span.id = 'password-show-toggle-span'; + span.appendChild(button); + + return span; +}; + +const createFormLayoutItem = (label: HTMLLabelElement, input: HTMLInputElement, localizationKey: string) => { const formLayoutItem = document.createElement('uui-form-layout-item') as UUIFormLayoutItemElement; + const errorId = input.getAttribute('aria-errormessage') || input.id + '-error'; + formLayoutItem.appendChild(label); formLayoutItem.appendChild(input); + const validationMessage = createValidationMessage(errorId); + formLayoutItem.appendChild(validationMessage); + + // Bind validation + input.oninput = () => validateInput(input, validationMessage, localizationKey); + input.onblur = () => validateInput(input, validationMessage, localizationKey); + return formLayoutItem; }; -const createForm = (elements: HTMLElement[]) => { - const styles = document.createElement('style'); - styles.innerHTML = authStyles; - const form = document.createElement('form'); - form.id = 'umb-login-form'; - form.name = 'login-form'; - form.spellcheck = false; +const createFormLayoutPasswordItem = ( + label: HTMLLabelElement, + input: HTMLInputElement, + showPasswordToggle: HTMLSpanElement, + requiredMessageKey: string +) => { + const formLayoutItem = document.createElement('uui-form-layout-item') as UUIFormLayoutItemElement; + const errorId = input.getAttribute('aria-errormessage') || input.id + '-error'; + + formLayoutItem.appendChild(label); + + const span = document.createElement('span'); + span.id = 'password-input-span'; + span.appendChild(input); + span.appendChild(showPasswordToggle); + formLayoutItem.appendChild(span); - elements.push(styles); - elements.forEach((element) => form.appendChild(element)); + const validationMessage = createValidationMessage(errorId); + formLayoutItem.appendChild(validationMessage); - return form; + // Bind validation + input.oninput = () => validateInput(input, validationMessage, requiredMessageKey); + input.onblur = () => validateInput(input, validationMessage, requiredMessageKey); + + return formLayoutItem; +}; + +const validateInput = (input: HTMLInputElement, validationElement: HTMLElement, requiredMessage = '') => { + validationElement.innerHTML = ''; + if (input.validity.valid) { + input.removeAttribute('aria-invalid'); + validationElement.classList.remove('active'); + validationElement.ariaLive = 'off'; + } else { + input.setAttribute('aria-invalid', 'true'); + + const localizeElement = document.createElement('umb-localize'); + localizeElement.innerHTML = input.validationMessage; + localizeElement.key = requiredMessage; + validationElement.appendChild(localizeElement); + + validationElement.classList.add('active'); + validationElement.ariaLive = 'assertive'; + } }; @customElement('umb-auth') @@ -105,14 +203,6 @@ export default class UmbAuthElement extends UmbLitElement { */ protected flow?: 'mfa' | 'reset-password' | 'invite-user'; - _form?: HTMLFormElement; - _usernameLayoutItem?: UUIFormLayoutItemElement; - _passwordLayoutItem?: UUIFormLayoutItemElement; - _usernameInput?: HTMLInputElement; - _passwordInput?: HTMLInputElement; - _usernameLabel?: HTMLLabelElement; - _passwordLabel?: HTMLLabelElement; - #authContext = new UmbAuthContext(this, UMB_AUTH_CONTEXT); constructor() { @@ -121,6 +211,16 @@ export default class UmbAuthElement extends UmbLitElement { (this as unknown as EventTarget).addEventListener('umb-login-flow', (e) => { if (e instanceof CustomEvent) { this.flow = e.detail.flow || undefined; + if (typeof e.detail.status !== 'undefined') { + const searchParams = new URLSearchParams(window.location.search); + if (e.detail.status === null) { + searchParams.delete('status'); + } else { + searchParams.set('status', e.detail.status); + } + const newRelativePathQuery = window.location.pathname + '?' + searchParams.toString(); + window.history.pushState(null, '', newRelativePathQuery); + } } this.requestUpdate(); }); @@ -133,21 +233,35 @@ export default class UmbAuthElement extends UmbLitElement { // Register the main package for Umbraco.Auth umbExtensionsRegistry.registerMany(extensions); - setTimeout(() => { - requestAnimationFrame(() => { - this.#initializeForm(); - }); - }, 100); + // Wait for localization to be ready before loading the form + await this.#waitForLocalization(); + + this.#initializeForm(); } - disconnectedCallback() { - super.disconnectedCallback(); - this._usernameLayoutItem?.remove(); - this._passwordLayoutItem?.remove(); - this._usernameLabel?.remove(); - this._usernameInput?.remove(); - this._passwordLabel?.remove(); - this._passwordInput?.remove(); + async #waitForLocalization(): Promise { + return new Promise((resolve, reject) => { + let retryCount = 0; + // Retries 40 times with a 50ms interval = 2 seconds + const maxRetries = 40; + + // We check periodically until it is available or we reach the max retries + const checkInterval = setInterval(() => { + // If we reach max retries, we give up and reject the promise + if (retryCount > maxRetries) { + clearInterval(checkInterval); + reject('Localization not available'); + return; + } + // Check if localization is available + if (this.localize.term('auth_showPassword') !== 'auth_showPassword') { + clearInterval(checkInterval); + resolve(); + return; + } + retryCount++; + }, 50); + }); } /** @@ -158,38 +272,65 @@ export default class UmbAuthElement extends UmbLitElement { * @private */ #initializeForm() { - this._usernameInput = createInput({ + const usernameInput = createInput({ id: 'username-input', type: 'text', name: 'username', autocomplete: 'username', + errorId: 'username-input-error', inputmode: this.usernameIsEmail ? 'email' : '', autofocus: true, }); - this._passwordInput = createInput({ + const passwordInput = createInput({ id: 'password-input', type: 'password', name: 'password', autocomplete: 'current-password', + errorId: 'password-input-error', inputmode: '', }); - this._usernameLabel = createLabel({ + const passwordShowPasswordToggleButton = createShowPasswordToggleButton({ + id: 'password-show-toggle', + name: 'password-show-toggle', + ariaLabelShowPassword: this.localize.term('auth_showPassword'), + ariaLabelHidePassword: this.localize.term('auth_hidePassword'), + }); + const passwordShowPasswordToggleItem = createShowPasswordToggleItem(passwordShowPasswordToggleButton); + const usernameLabel = createLabel({ forId: 'username-input', localizeAlias: this.usernameIsEmail ? 'auth_email' : 'auth_username', localizeFallback: this.usernameIsEmail ? 'Email' : 'Username', }); - this._passwordLabel = createLabel({ + const passwordLabel = createLabel({ forId: 'password-input', localizeAlias: 'auth_password', localizeFallback: 'Password', }); - - this._usernameLayoutItem = createFormLayoutItem(this._usernameLabel, this._usernameInput); - this._passwordLayoutItem = createFormLayoutItem(this._passwordLabel, this._passwordInput); - - this._form = createForm([this._usernameLayoutItem, this._passwordLayoutItem]); - - this.insertAdjacentElement('beforeend', this._form); + const usernameLayoutItem = createFormLayoutItem( + usernameLabel, + usernameInput, + this.usernameIsEmail ? 'auth_requiredEmailValidationMessage' : 'auth_requiredUsernameValidationMessage' + ); + const passwordLayoutItem = createFormLayoutPasswordItem( + passwordLabel, + passwordInput, + passwordShowPasswordToggleItem, + 'auth_requiredPasswordValidationMessage' + ); + const style = document.createElement('style'); + style.innerHTML = authStyles; + document.head.appendChild(style); + + const form = document.createElement('form'); + form.id = 'umb-login-form'; + form.name = 'login-form'; + form.spellcheck = false; + form.setAttribute('novalidate', ''); + + form.appendChild(usernameLayoutItem); + form.appendChild(passwordLayoutItem); + + this.insertAdjacentElement('beforeend', form); } render() { @@ -246,12 +387,11 @@ export default class UmbAuthElement extends UmbLitElement { return html` `; default: - return html` - - - `; + return html` + + + + `; } } } diff --git a/src/Umbraco.Web.UI.Login/src/components/back-to-login-button.element.ts b/src/Umbraco.Web.UI.Login/src/components/back-to-login-button.element.ts index 9200baa52d4e..bef64afd1837 100644 --- a/src/Umbraco.Web.UI.Login/src/components/back-to-login-button.element.ts +++ b/src/Umbraco.Web.UI.Login/src/components/back-to-login-button.element.ts @@ -17,7 +17,7 @@ export default class UmbBackToLoginButtonElement extends UmbLitElement { } #handleClick() { - this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'login' } })); + this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'login', status: null } })); } static styles: CSSResultGroup = [ @@ -39,7 +39,7 @@ export default class UmbBackToLoginButtonElement extends UmbLitElement { display: inline-flex; line-height: 1; font-size: 14px; - font-family: var(--uui-font-family),sans-serif; + font-family: var(--uui-font-family), sans-serif; } button svg { width: 1rem; diff --git a/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts b/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts index 315bac444895..d89f434a8932 100644 --- a/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts +++ b/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts @@ -1,254 +1,263 @@ import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { css, type CSSResultGroup, html, nothing, when, customElement, property, queryAssignedElements, state } from '@umbraco-cms/backoffice/external/lit'; - -import { UMB_AUTH_CONTEXT } from '../../contexts'; +import { + css, + html, + nothing, + when, + customElement, + property, + queryAssignedElements, + state, +} from '@umbraco-cms/backoffice/external/lit'; + +import { UMB_AUTH_CONTEXT } from '../../contexts/index.js'; @customElement('umb-login-page') export default class UmbLoginPageElement extends UmbLitElement { - @property({type: Boolean, attribute: 'username-is-email'}) - usernameIsEmail = false; + @property({ type: Boolean, attribute: 'username-is-email' }) + usernameIsEmail = false; + + @queryAssignedElements({ flatten: true }) + protected slottedElements?: HTMLFormElement[]; + + @property({ type: Boolean, attribute: 'allow-password-reset' }) + allowPasswordReset = false; + + @state() + private _loginState?: UUIButtonState; + + @state() + private _loginError = ''; - @queryAssignedElements({flatten: true}) - protected slottedElements?: HTMLFormElement[]; + @state() + supportPersistLogin = false; - @property({type: Boolean, attribute: 'allow-password-reset'}) - allowPasswordReset = false; + #formElement?: HTMLFormElement; - @state() - private _loginState?: UUIButtonState; - - @state() - private _loginError = ''; + #authContext?: typeof UMB_AUTH_CONTEXT.TYPE; - @state() - supportPersistLogin = false; + constructor() { + super(); - #formElement?: HTMLFormElement; - - #authContext?: typeof UMB_AUTH_CONTEXT.TYPE; - - constructor() { - super(); - - this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => { - this.#authContext = authContext; - this.supportPersistLogin = authContext?.supportsPersistLogin ?? false; - }); - } - - async #onSlotChanged() { - this.#formElement = this.slottedElements?.find((el) => el.id === 'umb-login-form'); - - if (!this.#formElement) return; - - // We need to listen for the enter key to submit the form, because the uui-button does not support the native input fields submit event - this.#formElement.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - this.#onSubmitClick(); - } - }); - this.#formElement.onsubmit = this.#handleSubmit; - } - - #handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); - - if (!this.#authContext) return; - - const form = e.target as HTMLFormElement; - if (!form) return; - - const formData = new FormData(form); - - const username = formData.get('username') as string; - const password = formData.get('password') as string; - const persist = formData.has('persist'); - - if (!username || !password) { - this._loginError = this.localize.term('auth_userFailedLogin'); - this._loginState = 'failed'; - return; - } - - this._loginState = 'waiting'; - - const response = await this.#authContext.login({ - username, - password, - persist, - }); - - this._loginError = response.error || ''; - this._loginState = response.error ? 'failed' : 'success'; - - // Check for 402 status code indicating that MFA is required - if (response.status === 402) { - this.#authContext.isMfaEnabled = true; - if (response.twoFactorView) { - this.#authContext.twoFactorView = response.twoFactorView; - } - if (response.twoFactorProviders) { - this.#authContext.mfaProviders = response.twoFactorProviders; - } - - this.dispatchEvent(new CustomEvent('umb-login-flow', {composed: true, detail: {flow: 'mfa'}})); - return; - } - - if (response.error) { - return; - } - - const returnPath = this.#authContext.returnPath; - - if (returnPath) { - location.href = returnPath; - } - }; - - get #greetingLocalizationKey() { - return [ - 'auth_greeting0', - 'auth_greeting1', - 'auth_greeting2', - 'auth_greeting3', - 'auth_greeting4', - 'auth_greeting5', - 'auth_greeting6', - ][new Date().getDay()]; - } - - #onSubmitClick = () => { - this.#formElement?.requestSubmit(); - }; - - render() { - return html` - - -
- ${when( - this.supportPersistLogin, - () => html` - - - Remember me - - ` - )} - ${when( - this.allowPasswordReset, - () => - html` - ` - )} -
- - - ${this.#renderErrorMessage()} - `; - } - - #renderErrorMessage() { - if (!this._loginError || this._loginState !== 'failed') return nothing; - - return html`${this._loginError}`; - } - - #handleForgottenPassword() { - this.dispatchEvent(new CustomEvent('umb-login-flow', {composed: true, detail: {flow: 'reset'}})); - } - - static styles: CSSResultGroup = [ - css` - :host { - display: flex; - flex-direction: column; - } - - #header { - text-align: center; - display: flex; - flex-direction: column; - gap: var(--uui-size-space-5); - } - - #header span { - color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ - font-size: 14px; - } - - #greeting { - color: var(--uui-color-interactive); - text-align: center; - font-weight: 400; - font-size: var(--header-font-size); - margin: 0 0 var(--uui-size-layout-1); - line-height: 1.2; - } - - #umb-login-button { - margin-top: var(--uui-size-space-4); - width: 100%; - } - - #forgot-password { - cursor: pointer; - background: none; - border: 0; - height: 1rem; - color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ - gap: var(--uui-size-space-1); - align-self: center; - text-decoration: none; - display: inline-flex; - line-height: 1; - font-size: 14px; - font-family: var(--uui-font-family),sans-serif; - margin-left: auto; - margin-bottom: var(--uui-size-space-3); - } - - #forgot-password:hover { - color: var(--uui-color-interactive-emphasis); - } - - .text-error { - margin-top: var(--uui-size-space-4); - } - - .text-danger { - color: var(--uui-color-danger-standalone); - } - - #secondary-actions { - display: flex; - align-items: center; - justify-content: space-between; - } - `, - ]; + this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => { + this.#authContext = authContext; + this.supportPersistLogin = authContext?.supportsPersistLogin ?? false; + }); + } + + async #onSlotChanged() { + this.#formElement = this.slottedElements?.find((el) => el.id === 'umb-login-form'); + + if (!this.#formElement) return; + + this.#formElement.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.#onSubmitClick(); + } + }); + + this.#formElement.onsubmit = this.#handleSubmit; + } + + #handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + + this._loginError = ''; + this._loginState = undefined; + if (!this.#authContext) return; + + const form = e.target as HTMLFormElement; + if (!form) return; + + if (!form?.checkValidity()) { + return; + } + + const formData = new FormData(form); + + const username = formData.get('username') as string; + const password = formData.get('password') as string; + const persist = formData.has('persist'); + + if (!username || !password) { + return; + } + + this._loginState = 'waiting'; + + const response = await this.#authContext.login({ + username, + password, + persist, + }); + + this._loginError = response.error || ''; + this._loginState = response.error ? 'failed' : 'success'; + + // Check for 402 status code indicating that MFA is required + if (response.status === 402) { + this.#authContext.isMfaEnabled = true; + if (response.twoFactorView) { + this.#authContext.twoFactorView = response.twoFactorView; + } + if (response.twoFactorProviders) { + this.#authContext.mfaProviders = response.twoFactorProviders; + } + + this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'mfa' } })); + return; + } + + if (response.error) { + return; + } + + const returnPath = this.#authContext.returnPath; + + if (returnPath) { + location.href = returnPath; + } + }; + + get #greetingLocalizationKey() { + return [ + 'auth_greeting0', + 'auth_greeting1', + 'auth_greeting2', + 'auth_greeting3', + 'auth_greeting4', + 'auth_greeting5', + 'auth_greeting6', + ][new Date().getDay()]; + } + + #onSubmitClick = () => { + this.#formElement?.requestSubmit(); + }; + + render() { + return html` + + +
+ ${when( + this.supportPersistLogin, + () => html` + + Remember me + + ` + )} + ${when( + this.allowPasswordReset, + () => + html` ` + )} +
+ + + ${this.#renderErrorMessage()} + `; + } + + #renderErrorMessage() { + if (!this._loginError || this._loginState !== 'failed') return nothing; + + return html`${this._loginError}`; + } + + #handleForgottenPassword() { + this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'reset' } })); + } + + static readonly styles = [ + css` + :host { + display: flex; + flex-direction: column; + } + + #header { + text-align: center; + display: flex; + flex-direction: column; + gap: var(--uui-size-space-5); + } + + #header span { + color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ + font-size: 14px; + } + + #greeting { + color: var(--uui-color-interactive); + text-align: center; + font-weight: 400; + font-size: var(--header-font-size); + margin: 0 0 var(--uui-size-layout-1); + line-height: 1.2; + } + + #umb-login-button { + margin-top: var(--uui-size-space-4); + width: 100%; + } + + #forgot-password { + cursor: pointer; + background: none; + border: 0; + height: 1rem; + color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ + gap: var(--uui-size-space-1); + align-self: center; + text-decoration: none; + display: inline-flex; + line-height: 1; + font-size: 14px; + font-family: var(--uui-font-family), sans-serif; + margin-left: auto; + margin-bottom: var(--uui-size-space-3); + } + + #forgot-password:hover { + color: var(--uui-color-interactive-emphasis); + } + + .text-error { + margin-top: var(--uui-size-space-4); + } + + .text-danger { + color: var(--uui-color-danger-standalone); + } + + #secondary-actions { + display: flex; + align-items: center; + justify-content: space-between; + } + `, + ]; } declare global { - interface HTMLElementTagNameMap { - 'umb-login-page': UmbLoginPageElement; - } + interface HTMLElementTagNameMap { + 'umb-login-page': UmbLoginPageElement; + } } diff --git a/src/Umbraco.Web.UI.Login/src/localization/lang/da.ts b/src/Umbraco.Web.UI.Login/src/localization/lang/da.ts index fb15444c0d18..4bfb57f0c717 100644 --- a/src/Umbraco.Web.UI.Login/src/localization/lang/da.ts +++ b/src/Umbraco.Web.UI.Login/src/localization/lang/da.ts @@ -1,51 +1,61 @@ import type { UmbLocalizationDictionary } from '@umbraco-cms/backoffice/localization-api'; export default { - auth: { - continue: 'Fortsæt', - validate: 'Indsend', - login: 'Log ind', - email: 'E-mail', - username: 'Brugernavn', - password: 'Adgangskode', - submit: 'Indsend', - required: 'Påkrævet', - success: 'Succes', - forgottenPassword: 'Glemt adgangskode?', - forgottenPasswordInstruction: 'En e-mail vil blive sendt til den angivne adresse med et link til at nulstille din adgangskode', - requestPasswordResetConfirmation: 'En e-mail med instruktioner for nulstilling af adgangskoden vil blive sendt til den angivne adresse, hvis det matcher vores optegnelser', - setPasswordConfirmation: 'Din adgangskode er blevet opdateret', - rememberMe: 'Husk mig', - error: 'Fejl', - defaultError: 'Der er opstået en ukendt fejl.', - errorInPasswordFormat: 'Kodeordet skal være på minimum %0% tegn og indeholde mindst %1% alfanumeriske tegn.', - passwordMismatch: 'Adgangskoderne er ikke ens.', - passwordMinLength: 'Adgangskoden skal være mindst {0} tegn lang.', - passwordIsBlank: 'Din nye adgangskode kan ikke være tom.', - userFailedLogin: 'Ups! Vi kunne ikke logge dig ind. Tjek at dit brugernavn og adgangskode er korrekt og prøv igen.', - userLockedOut: 'Din konto er blevet låst. Prøv igen senere.', - receivedErrorFromServer: 'Der skete en fejl på serveren', - resetCodeExpired: 'Det link, du har klikket på, er ugyldigt eller udløbet', - userInviteWelcomeMessage: 'Hej og velkommen til Umbraco! På bare 1 minut vil du være klar til at komme i gang, vi skal bare have dig til at oprette en adgangskode.', - userInviteExpiredMessage: 'Velkommen til Umbraco! Desværre er din invitation udløbet. Kontakt din administrator og bed om at gensende invitationen.', - newPassword: 'Ny adgangskode', - confirmNewPassword: 'Bekræft adgangskode', - greeting0: 'Velkommen', - greeting1: 'Velkommen', - greeting2: 'Velkommen', - greeting3: 'Velkommen', - greeting4: 'Velkommen', - greeting5: 'Velkommen', - greeting6: 'Velkommen', - mfaTitle: 'Sidste skridt!', - mfaCodeInputHelp: 'Indtast venligst bekræftelseskoden', - mfaText: 'Du har aktiveret multi-faktor godkendelse. Du skal nu bekræfte din identitet.', - mfaMultipleText: 'Vælg venligst en godkendelsesmetode', - mfaCodeInput: 'Kode', - mfaInvalidCode: 'Forkert kode indtastet', - signInWith: 'Log ind med {0}', - returnToLogin: 'Tilbage til log ind', - localLoginDisabled: 'Desværre er det ikke muligt at logge ind direkte. Det er blevet deaktiveret af en login-udbyder.', - friendlyGreeting: 'Hej!', - }, + auth: { + continue: 'Fortsæt', + validate: 'Indsend', + login: 'Log ind', + email: 'E-mail', + username: 'Brugernavn', + password: 'Adgangskode', + submit: 'Indsend', + required: 'Påkrævet', + success: 'Succes', + forgottenPassword: 'Glemt adgangskode?', + forgottenPasswordInstruction: + 'En e-mail vil blive sendt til den angivne adresse med et link til at nulstille din adgangskode', + requestPasswordResetConfirmation: + 'En e-mail med instruktioner for nulstilling af adgangskoden vil blive sendt til den angivne adresse, hvis det matcher vores optegnelser', + setPasswordConfirmation: 'Din adgangskode er blevet opdateret', + rememberMe: 'Husk mig', + error: 'Fejl', + defaultError: 'Der er opstået en ukendt fejl.', + errorInPasswordFormat: 'Kodeordet skal være på minimum %0% tegn og indeholde mindst %1% alfanumeriske tegn.', + passwordMismatch: 'Adgangskoderne er ikke ens.', + passwordMinLength: 'Adgangskoden skal være mindst {0} tegn lang.', + passwordIsBlank: 'Din nye adgangskode kan ikke være tom.', + userFailedLogin: 'Ups! Vi kunne ikke logge dig ind. Tjek at dit brugernavn og adgangskode er korrekt og prøv igen.', + userLockedOut: 'Din konto er blevet låst. Prøv igen senere.', + receivedErrorFromServer: 'Der skete en fejl på serveren', + resetCodeExpired: 'Det link, du har klikket på, er ugyldigt eller udløbet', + userInviteWelcomeMessage: + 'Hej og velkommen til Umbraco! På bare 1 minut vil du være klar til at komme i gang, vi skal bare have dig til at oprette en adgangskode.', + userInviteExpiredMessage: + 'Velkommen til Umbraco! Desværre er din invitation udløbet. Kontakt din administrator og bed om at gensende invitationen.', + newPassword: 'Ny adgangskode', + confirmNewPassword: 'Bekræft adgangskode', + greeting0: 'Velkommen', + greeting1: 'Velkommen', + greeting2: 'Velkommen', + greeting3: 'Velkommen', + greeting4: 'Velkommen', + greeting5: 'Velkommen', + greeting6: 'Velkommen', + mfaTitle: 'Sidste skridt!', + mfaCodeInputHelp: 'Indtast venligst bekræftelseskoden', + mfaText: 'Du har aktiveret multi-faktor godkendelse. Du skal nu bekræfte din identitet.', + mfaMultipleText: 'Vælg venligst en godkendelsesmetode', + mfaCodeInput: 'Kode', + mfaInvalidCode: 'Forkert kode indtastet', + signInWith: 'Log ind med {0}', + returnToLogin: 'Tilbage til log ind', + localLoginDisabled: + 'Desværre er det ikke muligt at logge ind direkte. Det er blevet deaktiveret af en login-udbyder.', + friendlyGreeting: 'Hej!', + requiredEmailValidationMessage: 'Udfyld venligst en e-mail', + requiredUsernameValidationMessage: 'Udfyld venligst et brugernavn', + requiredPasswordValidationMessage: 'Udfyld venligst en adgangskode', + showPassword: 'Vis adgangskode', + hidePassword: 'Skjul adgangskode', + }, } satisfies UmbLocalizationDictionary; diff --git a/src/Umbraco.Web.UI.Login/src/localization/lang/en-us.ts b/src/Umbraco.Web.UI.Login/src/localization/lang/en-us.ts index e5fab358c3c5..40895f2513f3 100644 --- a/src/Umbraco.Web.UI.Login/src/localization/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Login/src/localization/lang/en-us.ts @@ -2,54 +2,6 @@ import type { UmbLocalizationDictionary } from '@umbraco-cms/backoffice/localiza export default { auth: { - continue: 'Continue', - validate: 'Validate', - login: 'Login', - email: 'E-mail', - username: 'Username', - password: 'Password', - submit: 'Submit', - required: 'Required', - success: 'Success', - forgottenPassword: 'Forgotten password?', - forgottenPasswordInstruction: 'An email will be sent to the address specified with a link to reset your password', - requestPasswordResetConfirmation: - 'An email with password reset instructions will be sent to the specified address if it matched our records', - setPasswordConfirmation: 'Your password has been updated', - rememberMe: 'Remember me', - error: 'Error', - defaultError: 'An error occurred while processing your request.', - errorInPasswordFormat: - 'The password must be at least {0} characters long and contain at least {1} special characters.', - passwordMismatch: 'The confirmed password does not match the new password!', - passwordMinLength: 'The password must be at least {0} characters long.', - passwordIsBlank: 'The password cannot be blank.', - userFailedLogin: "Oops! We couldn't log you in. Please check your credentials and try again.", - userLockedOut: 'Your account has been locked out. Please try again later.', - receivedErrorFromServer: 'Received an error from the server', - resetCodeExpired: 'The link you have clicked on is invalid or has expired', - userInviteWelcomeMessage: - 'Hello there and welcome to Umbraco! In just 1 minute you’ll be good to go, we just need you to setup a password.', - userInviteExpiredMessage: - 'Welcome to Umbraco! Unfortunately your invite has expired. Please contact your administrator and ask them to resend it.', - newPassword: 'New password', - confirmNewPassword: 'Confirm password', - greeting0: 'Welcome', - greeting1: 'Welcome', - greeting2: 'Welcome', - greeting3: 'Welcome', - greeting4: 'Welcome', - greeting5: 'Welcome', - greeting6: 'Welcome', - mfaTitle: 'One last step', - mfaCodeInputHelp: 'Enter the code from your authenticator app', - mfaText: 'You have enabled 2-factor authentication and must verify your identity.', - mfaMultipleText: 'Please choose a 2-factor provider', - mfaCodeInput: 'Verification code', - mfaInvalidCode: 'Invalid code entered', - signInWith: 'Sign in with {0}', - returnToLogin: 'Return to login', - localLoginDisabled: 'Unfortunately, direct login is not possible. It has been disabled by a provider.', friendlyGreeting: 'Hi there', }, } satisfies UmbLocalizationDictionary; diff --git a/src/Umbraco.Web.UI.Login/src/localization/lang/en.ts b/src/Umbraco.Web.UI.Login/src/localization/lang/en.ts index edbecc1972ea..f487ff739c49 100644 --- a/src/Umbraco.Web.UI.Login/src/localization/lang/en.ts +++ b/src/Umbraco.Web.UI.Login/src/localization/lang/en.ts @@ -1,51 +1,60 @@ import type { UmbLocalizationDictionary } from '@umbraco-cms/backoffice/localization-api'; export default { - auth: { - continue: 'Continue', - validate: 'Validate', - login: 'Login', - email: 'E-mail', - username: 'Username', - password: 'Password', - submit: 'Submit', - required: 'Required', - success: 'Success', - forgottenPassword: 'Forgotten password?', - forgottenPasswordInstruction: 'An email will be sent to the address specified with a link to reset your password', - requestPasswordResetConfirmation: 'An email with password reset instructions will be sent to the specified address if it matched our records', - setPasswordConfirmation: 'Your password has been updated', - rememberMe: 'Remember me', - error: 'Error', - defaultError: 'An error occurred while processing your request.', - errorInPasswordFormat: 'The password must be at least {0} characters long and contain at least {1} special characters.', - passwordMismatch: 'The confirmed password does not match the new password!', - passwordMinLength: 'The password must be at least {0} characters long.', - passwordIsBlank: 'The password cannot be blank.', - userFailedLogin: 'Oops! We couldn\'t log you in. Please check your credentials and try again.', - userLockedOut: 'Your account has been locked out. Please try again later.', - receivedErrorFromServer: 'Received an error from the server', - resetCodeExpired: 'The link you have clicked on is invalid or has expired', - userInviteWelcomeMessage: 'Hello there and welcome to Umbraco! In just 1 minute you’ll be good to go, we just need you to setup a password.', - userInviteExpiredMessage: 'Welcome to Umbraco! Unfortunately your invite has expired. Please contact your administrator and ask them to resend it.', - newPassword: 'New password', - confirmNewPassword: 'Confirm password', - greeting0: 'Welcome', - greeting1: 'Welcome', - greeting2: 'Welcome', - greeting3: 'Welcome', - greeting4: 'Welcome', - greeting5: 'Welcome', - greeting6: 'Welcome', - mfaTitle: 'One last step', - mfaCodeInputHelp: 'Enter the code from your authenticator app', - mfaText: 'You have enabled 2-factor authentication and must verify your identity.', - mfaMultipleText: 'Please choose a 2-factor provider', - mfaCodeInput: 'Verification code', - mfaInvalidCode: 'Invalid code entered', - signInWith: 'Sign in with {0}', - returnToLogin: 'Return to login', - localLoginDisabled: 'Unfortunately, direct login is not possible. It has been disabled by a provider.', - friendlyGreeting: 'Hello', - }, + auth: { + continue: 'Continue', + validate: 'Validate', + login: 'Login', + email: 'E-mail', + username: 'Username', + password: 'Password', + submit: 'Submit', + required: 'Required', + success: 'Success', + forgottenPassword: 'Forgotten password?', + forgottenPasswordInstruction: 'An email will be sent with a link to reset your password', + requestPasswordResetConfirmation: + 'We sent an email with password reset instructions, if the email address matches a registered user.', + setPasswordConfirmation: 'Your password has been updated', + rememberMe: 'Remember me', + error: 'Error', + defaultError: 'An error occurred while processing your request.', + errorInPasswordFormat: + 'The password must be at least {0} characters long and contain at least {1} special characters.', + passwordMismatch: 'The confirmed password does not match the new password!', + passwordMinLength: 'The password must be at least {0} characters long.', + passwordIsBlank: 'The password cannot be blank.', + userFailedLogin: "Oops! We couldn't log you in. Please check your credentials and try again.", + userLockedOut: 'Your account has been locked out. Please try again later.', + receivedErrorFromServer: 'Received an error from the server', + resetCodeExpired: 'The link you have clicked on is invalid or has expired', + userInviteWelcomeMessage: + "Hello there and welcome to Umbraco! In just 1 minute you'll be good to go, we just need you to setup a password.", + userInviteExpiredMessage: + 'Welcome to Umbraco! Unfortunately your invite has expired. Please contact your administrator and ask them to resend it.', + newPassword: 'New password', + confirmNewPassword: 'Confirm password', + greeting0: 'Welcome', + greeting1: 'Welcome', + greeting2: 'Welcome', + greeting3: 'Welcome', + greeting4: 'Welcome', + greeting5: 'Welcome', + greeting6: 'Welcome', + mfaTitle: 'One last step', + mfaCodeInputHelp: 'Enter the code from your authenticator app', + mfaText: 'You have enabled 2-factor authentication and must verify your identity.', + mfaMultipleText: 'Please choose a 2-factor provider', + mfaCodeInput: 'Verification code', + mfaInvalidCode: 'Invalid code entered', + signInWith: 'Sign in with {0}', + returnToLogin: 'Return to login', + localLoginDisabled: 'Unfortunately, direct login is not possible. It has been disabled by a provider.', + friendlyGreeting: 'Hello', + requiredEmailValidationMessage: 'Please fill in an email', + requiredUsernameValidationMessage: 'Please fill in a username', + requiredPasswordValidationMessage: 'Please fill in a password', + showPassword: 'Show password', + hidePassword: 'Hide password', + }, } satisfies UmbLocalizationDictionary; diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 2ade2ca396d8..a9c363c8a0cd 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -57,6 +57,11 @@ <_ContentIncludedByDefault Remove="umbraco\UmbracoBackOffice\Default.cshtml" /> + + + + + diff --git a/templates/UmbracoExtension/Umbraco.Extension.csproj b/templates/UmbracoExtension/Umbraco.Extension.csproj index 4bc359eaa809..d3e00b148a00 100644 --- a/templates/UmbracoExtension/Umbraco.Extension.csproj +++ b/templates/UmbracoExtension/Umbraco.Extension.csproj @@ -39,19 +39,4 @@ - - - - - - - - - - - <_ClientAssetsBuildOutput Include="wwwroot\App_Plugins\**" /> - - - - diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 08bccb81779f..509985c5b118 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -7,14 +7,14 @@ "name": "acceptancetest", "hasInstallScript": true, "dependencies": { - "@umbraco/json-models-builders": "^2.0.41", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.10", + "@umbraco/json-models-builders": "^2.0.42", + "@umbraco/playwright-testhelpers": "^17.0.6", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" }, "devDependencies": { - "@playwright/test": "1.50", + "@playwright/test": "1.56", "@types/node": "^20.9.0", "prompt": "^1.2.0", "tslib": "^2.4.0", @@ -32,13 +32,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", - "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.50.1" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -58,21 +58,21 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.41", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.41.tgz", - "integrity": "sha512-rCNUHCOpcuWIj7xUhk0lpcn4jzk9y82jHs9FSb7kxH716AnDyYvwuI+J0Ayd4hhWtXXqNCRqugCNYjG+rvzshQ==", + "version": "2.0.42", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.42.tgz", + "integrity": "sha512-5Zh/dSBGSKD9s0soemNnd5qT2h4gsKfTmQou/X34kqELSln333XMMfg+rbHfMleDwSBxh4dWAulntQFsfX0VtA==", "license": "MIT", "dependencies": { "camelize": "^1.0.1" } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "17.0.0-beta.10", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.10.tgz", - "integrity": "sha512-ePvtWK2IG/j3TIL1w7xkZR63FHM32hIjZxaxJOQ4rYNuVxBKT7TTKEvASfdwpDBFnlAN186xZRGA9KJq+Jxijg==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.6.tgz", + "integrity": "sha512-M0e5HJCqSTDxORFhebaNNGzBB4v6+77MerK6ctG1f+bU3JHfmbGZr4A4HDkD9eAeU7WGu5q7xoASdI0J1wqb1w==", "license": "MIT", "dependencies": { - "@umbraco/json-models-builders": "2.0.41", + "@umbraco/json-models-builders": "2.0.42", "node-fetch": "^2.6.7" } }, @@ -189,13 +189,13 @@ } }, "node_modules/playwright": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", - "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.50.1" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -208,9 +208,9 @@ } }, "node_modules/playwright-core": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", - "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 0f160e4733ff..7528914b7dc9 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -11,18 +11,19 @@ "createTest": "node createTest.js", "smokeTest": "npx playwright test DefaultConfig --grep \"@smoke\"", "smokeTestSqlite": "npx playwright test DefaultConfig --grep \"@smoke\" --grep-invert \"Users\"", - "releaseTest": "npx playwright test DefaultConfig --grep \"@release\"" + "releaseTest": "npx playwright test DefaultConfig --grep \"@release\"", + "testWindows": "npx playwright test DefaultConfig --grep-invert \"RelationType\"" }, "devDependencies": { - "@playwright/test": "1.50", + "@playwright/test": "1.56", "@types/node": "^20.9.0", "prompt": "^1.2.0", "tslib": "^2.4.0", "typescript": "^4.8.3" }, "dependencies": { - "@umbraco/json-models-builders": "^2.0.41", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.10", + "@umbraco/json-models-builders": "^2.0.42", + "@umbraco/playwright-testhelpers": "^17.0.6", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts index 72776856bdeb..f31d53bcee3a 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts @@ -65,6 +65,17 @@ export default defineConfig({ storageState: STORAGE_STATE } }, + { + name: 'entityDataPicker', + testMatch: 'EntityDataPicker/**/*.spec.ts', + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + // Use prepared auth state. + ignoreHTTPSErrors: true, + storageState: STORAGE_STATE + } + }, { name: 'deliveryApi', testMatch: 'DeliveryApi/**', diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ContentWithBlockGrid.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ContentWithBlockGrid.spec.ts index b16ede4a08c5..6e8a92a7c4df 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ContentWithBlockGrid.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ContentWithBlockGrid.spec.ts @@ -81,7 +81,7 @@ test('can add a block element in the content', async ({umbracoApi, umbracoUi}) = // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.enterTextstring(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButton(); @@ -160,8 +160,7 @@ test('cannot add number of block element greater than the maximum amount', {tag: // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.waitForTimeout(500); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.clickCreateModalButton(); // Assert @@ -196,7 +195,7 @@ test('can set the label of block element in the content', async ({umbracoApi, um // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButton(); @@ -217,7 +216,7 @@ test('can set the number of columns for the layout in the content', async ({umbr // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButton(); @@ -245,7 +244,7 @@ test('can add settings model for the block in the content', async ({umbracoApi, // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.enterTextstring(contentBlockInputText); await umbracoUi.content.clickAddBlockSettingsTabButton(); await umbracoUi.content.enterTextArea(settingBlockInputText); @@ -298,7 +297,7 @@ test('can add a block element with inline editing mode enabled', async ({umbraco // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.enterTextstring(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveAndPublishButton(); @@ -329,7 +328,7 @@ test('can add an invariant block element with an invariant RTE Tiptap in the con // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); @@ -363,7 +362,7 @@ test('can add a variant block element with variant RTE Tiptap in the content', a // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); @@ -399,7 +398,7 @@ test('can add a variant block element with invariant RTE Tiptap in the content', // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockList/ContentWithBlockList.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockList/ContentWithBlockList.spec.ts index 7fed44f11967..398d6aead1f8 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockList/ContentWithBlockList.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockList/ContentWithBlockList.spec.ts @@ -194,7 +194,7 @@ test('can add settings model for the block in the content', async ({umbracoApi, // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.enterTextstring(contentBlockInputText); await umbracoUi.content.clickAddBlockSettingsTabButton(); await umbracoUi.content.enterTextArea(settingBlockInputText); @@ -276,7 +276,7 @@ test('can add an invariant block element with invariant RTE Tiptap in the conten // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButton(); @@ -310,7 +310,7 @@ test('can add a variant block element with variant RTE Tiptap in the content', a // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); @@ -346,7 +346,7 @@ test('can add a variant block element with invariant RTE Tiptap in the content', // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickAddBlockElementButton(); - await umbracoUi.content.clickTextButtonWithName(customElementTypeName); + await umbracoUi.content.clickBlockElementWithName(customElementTypeName); await umbracoUi.content.enterRTETipTapEditor(inputText); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts index 0cfdccd38baf..6c3f51690fbb 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts @@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue); }); -test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => { +// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680 +test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => { // Arrange await umbracoApi.document.ensureNameNotExists(secondContentName); await umbracoApi.document.createDefaultDocumentWithABlockGridEditorAndBlockWithValue(contentName, documentTypeName, blockGridDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, richTextDataTypeUiAlias); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockListBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockListBlocks.spec.ts index 615112ef4542..40ccfe598c37 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockListBlocks.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockListBlocks.spec.ts @@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue); }); -test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => { +// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680 +test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => { // Arrange await umbracoApi.document.ensureNameNotExists(secondContentName); await umbracoApi.document.createDefaultDocumentWithABlockListEditorAndBlockWithValue(contentName, documentTypeName, blockListDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, elementDataTypeUiAlias, groupName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts index a62f7b004e43..0459dc10c7ec 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts @@ -36,6 +36,8 @@ test('can create content with the image cropper data type', async ({umbracoApi, await umbracoUi.content.chooseDocumentType(documentTypeName); await umbracoUi.content.enterContentName(contentName); await umbracoUi.content.uploadFile(imageFilePath); + // Wait for the upload to complete + await umbracoUi.waitForTimeout(1000); await umbracoUi.content.clickSaveButton(); // Assert @@ -60,6 +62,8 @@ test('can publish content with the image cropper data type', {tag: '@smoke'}, as // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.uploadFile(imageFilePath); + // Wait for the upload to complete + await umbracoUi.waitForTimeout(1000); await umbracoUi.content.clickSaveAndPublishButton(); // Assert diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts index fc39dacff658..4933d3c196c6 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts @@ -114,7 +114,7 @@ test('can save a variant content node after removing embedded block in RTE', asy // Act await umbracoUi.content.clickInsertBlockButton(); - await umbracoUi.content.clickLinkWithName(elementTypeName); + await umbracoUi.content.clickBlockElementWithName(elementTypeName); await umbracoUi.content.enterTextstring(textStringValue); await umbracoUi.content.clickCreateModalButton(); await umbracoUi.content.clickSaveButtonForContent(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapToolbar.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapToolbar.spec.ts index 9c61a2495514..84d10ef71c80 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapToolbar.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapToolbar.spec.ts @@ -31,7 +31,7 @@ test('can add a media in RTE Tiptap property editor', async ({umbracoApi, umbrac // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickTipTapToolbarIconWithTitle(iconTitle); - await umbracoUi.content.selectMediaWithName(imageName); + await umbracoUi.content.selectMediaWithName(imageName, true); await umbracoUi.content.clickChooseModalButton(); await umbracoUi.content.clickMediaCaptionAltTextModalSubmitButton(); await umbracoUi.content.clickSaveAndPublishButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/TrashContent/TrashContent.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/TrashContent/TrashContent.spec.ts index f65a686143f5..0bed7422332a 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/TrashContent/TrashContent.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/TrashContent/TrashContent.spec.ts @@ -1,4 +1,4 @@ -import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; import {expect} from "@playwright/test"; let dataTypeId = ''; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts index 2196ef194b27..43efd6e55383 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts @@ -427,6 +427,7 @@ test('can add a thumbnail to a block', {tag: '@release'}, async ({umbracoApi, um // Act await umbracoUi.dataType.goToDataType(blockListEditorName); await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.waitForTimeout(500); await umbracoUi.dataType.chooseBlockThumbnailWithPath(mediaUrl); await umbracoUi.dataType.clickSubmitButton(); await umbracoUi.dataType.clickSaveButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeCollectionWorkspace.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeCollectionWorkspace.spec.ts index 93f16cf33221..fbbdef935065 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeCollectionWorkspace.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeCollectionWorkspace.spec.ts @@ -47,6 +47,7 @@ test('can create a data type folder using create options', async ({umbracoApi, u // Assert await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.waitForTimeout(500); // Wait for the folder to be fully created expect(await umbracoApi.dataType.doesNameExist(dataTypeFolderName)).toBeTruthy(); // Check if the created data type is displayed in the collection view and has correct icon await umbracoUi.dataType.clickDataTypesMenu(); @@ -89,6 +90,7 @@ test('can create a data type folder in a folder using create options', async ({u // Assert await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.waitForTimeout(500); // Wait for folder to be created expect(await umbracoApi.dataType.doesNameExist(childFolderName)).toBeTruthy(); // Check if the created data type is displayed in the collection view and has correct icon await umbracoUi.dataType.doesCollectionTreeItemTableRowHaveName(childFolderName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeFolder.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeFolder.spec.ts index 0a274fdd1ba6..78ade510fa5c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeFolder.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/DataTypeFolder.spec.ts @@ -43,6 +43,7 @@ test('can rename a data type folder', async ({umbracoApi, umbracoUi}) => { // Assert await umbracoUi.dataType.waitForDataTypeToBeRenamed(); + await umbracoUi.waitForTimeout(500); // Wait for the rename to be fully processed expect(await umbracoApi.dataType.doesNameExist(dataTypeFolderName)).toBeTruthy(); expect(await umbracoApi.dataType.doesNameExist(wrongDataTypeFolderName)).toBeFalsy(); }); @@ -99,6 +100,7 @@ test('can create a folder in a folder', async ({umbracoApi, umbracoUi}) => { // Assert await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.waitForTimeout(500); // Wait for folder to be created expect(await umbracoApi.dataType.doesNameExist(childFolderName)).toBeTruthy(); const dataTypeChildren = await umbracoApi.dataType.getChildren(dataTypeFolderId); expect(dataTypeChildren[0].name).toBe(childFolderName); @@ -120,6 +122,7 @@ test('can create a folder in a folder in a folder', async ({umbracoApi, umbracoU // Assert await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.waitForTimeout(500); // Wait for folder to be created expect(await umbracoApi.dataType.doesNameExist(childOfChildFolderName)).toBeTruthy(); const childrenFolderData = await umbracoApi.dataType.getChildren(childFolderId); expect(childrenFolderData[0].name).toBe(childOfChildFolderName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/Tiptap.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/Tiptap.spec.ts index 9d525a57e5f0..9c3e7ec46d27 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/Tiptap.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/Tiptap.spec.ts @@ -15,7 +15,7 @@ test.afterEach(async ({umbracoApi}) => { test('can create a rich text editor with tiptap', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { // Arrange - const tipTapLocatorName = 'Rich Text Editor [Tiptap]'; + const tipTapLocatorName = 'Rich Text Editor'; const tipTapAlias = 'Umbraco.RichText'; const tipTapUiAlias = 'Umb.PropertyEditorUi.Tiptap'; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/Dashboard/Profiling.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/Dashboard/Profiling.spec.ts index 7f5d864ba441..64e22f203ba4 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/Dashboard/Profiling.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/Dashboard/Profiling.spec.ts @@ -7,11 +7,11 @@ test('can update value of activate the profiler by default', async ({umbracoUi}) await umbracoUi.profiling.clickProfilingTab(); // Act + await umbracoUi.profiling.isActivateProfilerByDefaultToggleChecked(false); await umbracoUi.profiling.clickActivateProfilerByDefaultToggle(); - await umbracoUi.reloadPage(); - // TODO: We need to wait a bit to make sure the page is loaded after we have reloaded the page, otherwise it can be flaky and it might not find the toggle - await umbracoUi.waitForTimeout(500); // Assert + await umbracoUi.profiling.goToSection(ConstantHelper.sections.settings); + await umbracoUi.profiling.clickProfilingTab(); await umbracoUi.profiling.isActivateProfilerByDefaultToggleChecked(true); }); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeCollectionWorkspace.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeCollectionWorkspace.spec.ts index 855f93fe71d7..b452154a299b 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeCollectionWorkspace.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeCollectionWorkspace.spec.ts @@ -172,6 +172,7 @@ test('can create a document type folder in a folder using create options', async const childFolderName = 'Test Child Folder'; await umbracoApi.documentType.ensureNameNotExists(childFolderName); await umbracoApi.documentType.createFolder(documentFolderName); + await umbracoUi.waitForTimeout(500); // Wait for folder to be created before navigating to it await umbracoUi.documentType.goToDocumentType(documentFolderName); // Act diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeFolder.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeFolder.spec.ts index 4c571df2245f..6cf5f21ae3fa 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeFolder.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeFolder.spec.ts @@ -61,6 +61,7 @@ test('can rename a document type folder', async ({umbracoApi, umbracoUi}) => { // Assert await umbracoUi.documentType.waitForDocumentTypeToBeRenamed(); + await umbracoUi.waitForTimeout(500); // Wait for the rename to be fully processed expect(await umbracoApi.documentType.doesNameExist(oldFolderName)).toBeFalsy(); expect(await umbracoApi.documentType.doesNameExist(documentFolderName)).toBeTruthy(); await umbracoUi.documentType.isDocumentTreeItemVisible(oldFolderName, false); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts index fa19f68f4d2c..26d6fc5c957a 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts @@ -54,14 +54,14 @@ test('can rename a media type folder', async ({umbracoApi, umbracoUi}) => { await umbracoUi.mediaType.clickRootFolderCaretButton(); await umbracoUi.mediaType.clickActionsMenuForName(oldFolderName); await umbracoUi.mediaType.clickUpdateActionMenuOption(); - await umbracoUi.waitForTimeout(500); await umbracoUi.mediaType.enterFolderName(mediaTypeFolderName); await umbracoUi.mediaType.clickConfirmRenameButton(); // Assert await umbracoUi.mediaType.waitForMediaTypeToBeRenamed(); - const folder = await umbracoApi.mediaType.getByName(mediaTypeFolderName); - expect(folder.name).toBe(mediaTypeFolderName); + await umbracoUi.waitForTimeout(500); // Small wait to ensure the API has caught up + const folderData = await umbracoApi.mediaType.getByName(mediaTypeFolderName); + expect(folderData.name).toBe(mediaTypeFolderName); await umbracoUi.mediaType.isMediaTypeTreeItemVisible(oldFolderName, false); await umbracoUi.mediaType.isMediaTypeTreeItemVisible(mediaTypeFolderName, true); }); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/ContentStartNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/ContentStartNodes.spec.ts index b2445d458865..d5b7d00d1007 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/ContentStartNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/ContentStartNodes.spec.ts @@ -1,4 +1,4 @@ -import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; const testUser = ConstantHelper.testUserCredentials; let testUserCookieAndToken = {cookie: "", accessToken: "", refreshToken: ""}; @@ -57,7 +57,8 @@ test('can see root start node and children', async ({umbracoApi, umbracoUi}) => await umbracoUi.content.isChildContentInTreeVisible(rootDocumentName, childDocumentTwoName); }); -test('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { // Arrange await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId, [childDocumentOneId]); testUserCookieAndToken = await umbracoApi.user.loginToUser(testUser.name, testUser.email, testUser.password); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/MediaStartNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/MediaStartNodes.spec.ts index f16608353b50..07cbf792a423 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/MediaStartNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/User/MediaStartNodes.spec.ts @@ -1,4 +1,4 @@ -import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; const testUser = ConstantHelper.testUserCredentials; let testUserCookieAndToken = {cookie: "", accessToken: "", refreshToken: ""}; @@ -50,7 +50,8 @@ test('can see root media start node and children', async ({umbracoApi, umbracoUi await umbracoUi.media.isChildMediaVisible(rootFolderName, childFolderTwoName); }); -test('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { // Arrange await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId, [], false, [childFolderOneId]); testUserCookieAndToken = await umbracoApi.user.loginToUser(testUser.name, testUser.email, testUser.password); @@ -62,6 +63,7 @@ test('can see parent of start node but not access it', async ({umbracoApi, umbra // Assert await umbracoUi.media.isMediaTreeItemVisible(rootFolderName); await umbracoUi.media.goToMediaWithName(rootFolderName); + await umbracoUi.waitForTimeout(500); // Wait for workspace to load await umbracoUi.media.doesMediaWorkspaceHaveText('Access denied'); await umbracoUi.media.openMediaCaretButtonForName(rootFolderName); await umbracoUi.media.isChildMediaVisible(rootFolderName, childFolderOneName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/ContentStartNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/ContentStartNodes.spec.ts index 82178e73fce0..aa896ed49362 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/ContentStartNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/ContentStartNodes.spec.ts @@ -58,7 +58,8 @@ test('can see root start node and children', async ({umbracoApi, umbracoUi}) => await umbracoUi.content.isChildContentInTreeVisible(rootDocumentName, childDocumentTwoName); }); -test('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithDocumentStartNode(userGroupName, childDocumentOneId); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DefaultPermissionsInContent.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DefaultPermissionsInContent.spec.ts index 32837242d172..32a36e0e343c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DefaultPermissionsInContent.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DefaultPermissionsInContent.spec.ts @@ -71,7 +71,8 @@ test('can read content node with permission enabled', {tag: '@release'}, async ( await umbracoUi.content.doesDocumentHaveName(rootDocumentName); }); -test('can not read content node with permission disabled', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can not read content node with permission disabled', async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithReadPermission(userGroupName, false); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); @@ -161,6 +162,7 @@ test('can empty recycle bin with delete permission enabled', {tag: '@release'}, // Act await umbracoUi.content.clickRecycleBinButton(); + await umbracoUi.waitForTimeout(700); await umbracoUi.content.clickEmptyRecycleBinButton(); await umbracoUi.content.clickConfirmEmptyRecycleBinButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValueGranularPermission.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValueGranularPermission.spec.ts index c4795947503b..9cc0085c4c9f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValueGranularPermission.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValueGranularPermission.spec.ts @@ -1,5 +1,5 @@ import {expect} from '@playwright/test'; -import {AliasHelper, ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers'; const testUser = ConstantHelper.testUserCredentials; let testUserCookieAndToken = {cookie: "", accessToken: "", refreshToken: ""}; @@ -35,7 +35,8 @@ test.afterEach(async ({umbracoApi}) => { await umbracoApi.userGroup.ensureNameNotExists(userGroupName); }); -test('can only see property values for specific document with read UI enabled', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can only see property values for specific document with read UI enabled', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithPermissionsForSpecificDocumentAndTwoPropertyValues(userGroupName, firstDocumentId, documentTypeId, firstPropertyName[0], true, false, secondPropertyName[0], true, false); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValuePermission.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValuePermission.spec.ts index 2e84a852d2a7..b8ba9b7c3ff4 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValuePermission.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/DocumentPropertyValuePermission.spec.ts @@ -58,7 +58,8 @@ test('can see property values with UI read but not UI write permission', {tag: ' await umbracoUi.content.isPropertyEditorUiWithNameReadOnly('text-box'); }); -test('cannot open content without document read permission even with UI read permission', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('cannot open content without document read permission even with UI read permission', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithReadPermissionAndReadPropertyValuePermission(userGroupName, false, true); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/GranularPermissionsInContent.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/GranularPermissionsInContent.spec.ts index bd5b2f94d1db..cf67544d8849 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/GranularPermissionsInContent.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/GranularPermissionsInContent.spec.ts @@ -43,6 +43,7 @@ test.beforeEach(async ({umbracoApi}) => { documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNodeAndDataType(documentTypeName, childDocumentTypeId, dataTypeName, dataTypeId); firstDocumentId = await umbracoApi.document.createDocumentWithTextContent(firstDocumentName, documentTypeId, documentText, dataTypeName); secondDocumentId = await umbracoApi.document.createDocumentWithTextContent(secondDocumentName, documentTypeId, documentText, dataTypeName); + await umbracoApi.language.createDanishLanguage(); }); test.afterEach(async ({umbracoApi}) => { @@ -54,9 +55,11 @@ test.afterEach(async ({umbracoApi}) => { await umbracoApi.documentType.ensureNameNotExists(childDocumentTypeName); await umbracoApi.documentBlueprint.ensureNameNotExists(documentBlueprintName); await umbracoApi.userGroup.ensureNameNotExists(userGroupName); + await umbracoApi.language.ensureIsoCodeNotExists('da'); }); -test('can read a specific document with read permission enabled', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can read a specific document with read permission enabled', async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithReadPermissionForSpecificDocument(userGroupName, firstDocumentId); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); @@ -254,6 +257,7 @@ test('can sort children with sort children permission enabled', async ({umbracoA test('can set culture and hostnames for a specific content with culture and hostnames permission enabled', async ({umbracoApi, umbracoUi}) => { // Arrange const domainName = '/domain'; + const languageName = 'Danish'; userGroupId = await umbracoApi.userGroup.createUserGroupWithCultureAndHostnamesPermissionForSpecificDocument(userGroupName, firstDocumentId); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); testUserCookieAndToken = await umbracoApi.user.loginToUser(testUser.name, testUser.email, testUser.password); @@ -264,16 +268,17 @@ test('can set culture and hostnames for a specific content with culture and host await umbracoUi.content.clickActionsMenuForContent(firstDocumentName); await umbracoUi.content.clickCultureAndHostnamesActionMenuOption(); await umbracoUi.content.clickAddNewDomainButton(); - await umbracoUi.content.enterDomain(domainName); + await umbracoUi.content.enterDomain(domainName, 0); + await umbracoUi.content.selectDomainLanguageOption(languageName, 0); await umbracoUi.content.clickSaveModalButton(); // Assert await umbracoUi.content.waitForDomainToBeCreated(); - await umbracoUi.waitForTimeout(500); // Wait for the domain to be set + await umbracoUi.waitForTimeout(1000); // Wait for the domain to be set const document = await umbracoApi.document.getByName(firstDocumentName); const domains = await umbracoApi.document.getDomains(document.id); expect(domains.domains[0].domainName).toEqual(domainName); - expect(domains.domains[0].isoCode).toEqual('en-US'); + expect(domains.domains[0].isoCode).toEqual('da'); await umbracoUi.content.isActionsMenuForNameVisible(secondDocumentName, false); }); @@ -322,6 +327,7 @@ test('can rollback a specific content with rollback permission enabled', async ( await umbracoUi.content.clickRollbackContainerButton(); // Assert + await umbracoUi.content.isSuccessNotificationVisible(); await umbracoUi.content.goToContentWithName(firstDocumentName); await umbracoUi.content.doesDocumentPropertyHaveValue(dataTypeName, documentText); await umbracoUi.content.isActionsMenuForNameVisible(secondDocumentName, false); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/MediaStartNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/MediaStartNodes.spec.ts index be8b531d44b5..b747e3ece20d 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/MediaStartNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/Permissions/UserGroup/MediaStartNodes.spec.ts @@ -1,4 +1,4 @@ -import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; const testUser = ConstantHelper.testUserCredentials; let testUserCookieAndToken = {cookie: "", accessToken: "", refreshToken: ""}; @@ -50,7 +50,8 @@ test('can see root media start node and children', {tag: '@release'}, async ({um await umbracoUi.media.isChildMediaVisible(rootFolderName, childFolderTwoName); }); -test('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { +// Skip this test due to this issue: https://github.com/umbraco/Umbraco-CMS/issues/20505 +test.skip('can see parent of start node but not access it', async ({umbracoApi, umbracoUi}) => { // Arrange userGroupId = await umbracoApi.userGroup.createUserGroupWithMediaStartNode(userGroupName, childFolderOneId); await umbracoApi.user.setUserPermissions(testUser.name, testUser.email, testUser.password, userGroupId); @@ -63,6 +64,7 @@ test('can see parent of start node but not access it', async ({umbracoApi, umbra // Assert await umbracoUi.media.isMediaTreeItemVisible(rootFolderName); await umbracoUi.media.goToMediaWithName(rootFolderName); + await umbracoUi.waitForTimeout(500); // Wait for workspace to load await umbracoUi.media.doesMediaWorkspaceHaveText('Access denied'); await umbracoUi.media.openMediaCaretButtonForName(rootFolderName); await umbracoUi.media.isChildMediaVisible(rootFolderName, childFolderOneName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts index 51544486d528..2caf82691953 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts @@ -14,7 +14,7 @@ test.afterEach(async ({umbracoApi}) => { await umbracoApi.webhook.ensureNameNotExists(webhookName); }); -test('can create a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +test('can create a webhook', async ({umbracoApi, umbracoUi}) => { // Arrange const event = 'Content Deleted'; const webhookSiteUrl = umbracoApi.webhook.webhookSiteUrl + webhookSiteToken; @@ -122,7 +122,7 @@ test('can disable a webhook', async ({umbracoApi, umbracoUi}) => { await umbracoApi.webhook.isWebhookEnabled(webhookName, false); }); -test('cannot remove all events from a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +test('cannot remove all events from a webhook', async ({umbracoApi, umbracoUi}) => { // Arrange const event = 'Content Deleted'; await umbracoApi.webhook.createDefaultWebhook(webhookName, webhookSiteToken, event); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/collection-api.js b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/collection-api.js new file mode 100644 index 000000000000..4a1f014e0da3 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/collection-api.js @@ -0,0 +1,73 @@ +import {UmbControllerBase} from "@umbraco-cms/backoffice/class-api"; + +export class ExampleCustomPickerCollectionPropertyEditorDataSource extends UmbControllerBase { + collectionPickableFilter = (item) => item.isPickable; + + async requestCollection(args) { + const data = { + items: customItems, + total: customItems.length, + }; + + return {data}; + } + + async requestItems(uniques) { + const items = customItems.filter((x) => uniques.includes(x.unique)); + return {data: items}; + } + + async search(args) { + const items = customItems.filter((item) => + item.name?.toLowerCase().includes(args.query.toLowerCase()) + ); + const total = items.length; + + const data = { + items, + total, + }; + + return {data}; + } +} + +export {ExampleCustomPickerCollectionPropertyEditorDataSource as api}; + +const customItems = [ + { + unique: "1", + entityType: "example", + name: "Example 1", + icon: "icon-shape-triangle", + isPickable: true, + }, + { + unique: "2", + entityType: "example", + name: "Example 2", + icon: "icon-shape-triangle", + isPickable: true, + }, + { + unique: "3", + entityType: "example", + name: "Example 3", + icon: "icon-shape-triangle", + isPickable: true, + }, + { + unique: "4", + entityType: "example", + name: "Example 4", + icon: "icon-shape-triangle", + isPickable: false, + }, + { + unique: "5", + entityType: "example", + name: "Example 5", + icon: "icon-shape-triangle", + isPickable: true, + }, +]; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/tree-api.js b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/tree-api.js new file mode 100644 index 000000000000..3fc7a208d9b5 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/tree-api.js @@ -0,0 +1,129 @@ +import {UmbControllerBase} from "@umbraco-cms/backoffice/class-api"; + +export class MyPickerDataSource extends UmbControllerBase { + treePickableFilter = (treeItem) => + !!treeItem.unique && treeItem.entityType === "example"; + + searchPickableFilter = (searchItem) => + !!searchItem.unique && searchItem.entityType === "example"; + + async requestTreeRoot() { + const root = { + unique: null, + name: "Examples", + icon: "icon-folder", + hasChildren: true, + entityType: "example-root", + isFolder: true, + }; + + return {data: root}; + } + + async requestTreeRootItems() { + const rootItems = customItems.filter((item) => item.parent.unique === null); + + const data = { + items: rootItems, + total: rootItems.length, + }; + + return {data}; + } + + async requestTreeItemsOf(args) { + const items = customItems.filter( + (item) => + item.parent.entityType === args.parent.entityType && + item.parent.unique === args.parent.unique + ); + + const data = { + items: items, + total: items.length, + }; + + return {data}; + } + + async requestTreeItemAncestors() { + // TODO: implement when needed + return {data: []}; + } + + async requestItems(uniques) { + const items = customItems.filter((x) => uniques.includes(x.unique)); + return {data: items}; + } + + async search(args) { + const result = customItems.filter((item) => + item.name.toLowerCase().includes(args.query.toLowerCase()) + ); + + const data = { + items: result, + total: result.length, + }; + + return {data}; + } +} + +export {MyPickerDataSource as api}; + +const customItems = [ + { + unique: "1", + entityType: "example", + name: "Example 1", + icon: "icon-shape-triangle", + parent: {unique: null, entityType: "example-root"}, + isFolder: false, + hasChildren: false, + }, + { + unique: "2", + entityType: "example", + name: "Example 2", + icon: "icon-shape-triangle", + parent: {unique: null, entityType: "example-root"}, + isFolder: false, + hasChildren: false, + }, + { + unique: "3", + entityType: "example", + name: "Example 3", + icon: "icon-shape-triangle", + parent: {unique: null, entityType: "example-root"}, + isFolder: false, + hasChildren: false, + }, + { + unique: "4", + entityType: "example", + name: "Example 4", + icon: "icon-shape-triangle", + parent: {unique: "6", entityType: "example-folder"}, + isFolder: false, + hasChildren: false, + }, + { + unique: "5", + entityType: "example", + name: "Example 5", + icon: "icon-shape-triangle", + parent: {unique: "6", entityType: "example-folder"}, + isFolder: false, + hasChildren: false, + }, + { + unique: "6", + entityType: "example-folder", + name: "Example Folder 1", + parent: {unique: null, entityType: "example-root"}, + isFolder: true, + hasChildren: true, + }, +]; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/umbraco-package.json new file mode 100644 index 000000000000..08cbc8163dd6 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/App_Plugins/picker-data-source/umbraco-package.json @@ -0,0 +1,30 @@ +{ + "name": "My Picker Data Source", + "alias": "My.PickerDataSource", + "extensions": [ + { + "type": "propertyEditorDataSource", + "dataSourceType": "Umb.DataSourceType.Picker", + "alias": "My.PickerDataSource.Tree", + "name": "My Picker Tree Data Source", + "api": "/App_Plugins/picker-data-source/tree-api.js", + "meta": { + "icon": "icon-database", + "label": "My Picker Tree Data Source", + "description": "Some description goes here" + } + }, + { + "type": "propertyEditorDataSource", + "dataSourceType": "Umb.DataSourceType.Picker", + "alias": "My.PickerDataSource.Collection", + "name": "My Picker Collection Data Source", + "api": "/App_Plugins/picker-data-source/collection-api.js", + "meta": { + "icon": "icon-database", + "label": "My Picker Collection Data Source", + "description": "Some description goes here" + } + } + ] +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/appsettings.json new file mode 100644 index 000000000000..49d90bb5936e --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/AdditionalSetup/appsettings.json @@ -0,0 +1,58 @@ +{ + "$schema": "appsettings-schema.json", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "Configure": [ + { + "Name": "Console" + } + ] + } + } + ] + }, + "Umbraco": { + "CMS": { + "Unattended": { + "InstallUnattended": true, + "UnattendedUserName": "Playwright Test", + "UnattendedUserEmail": "playwright@umbraco.com", + "UnattendedUserPassword": "UmbracoAcceptance123!" + }, + "Content": { + "ContentVersionCleanupPolicy": { + "EnableCleanup": false + } + }, + "Global": { + "DisableElectionForSingleServer": true, + "InstallMissingDatabase": true, + "Id": "00000000-0000-0000-0000-000000000042", + "VersionCheckPeriod": 0, + "UseHttps": true + }, + "HealthChecks": { + "Notification": { + "Enabled": false + } + }, + "KeepAlive": { + "DisableKeepAliveTask": true + }, + "WebRouting": { + "UmbracoApplicationUrl": "https://localhost:44331/" + } + } + } +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerCollection.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerCollection.spec.ts new file mode 100644 index 000000000000..6e03d37ab225 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerCollection.spec.ts @@ -0,0 +1,127 @@ +import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const dataTypeName = 'EntityPickerWithCollection'; +const collectionDataSourceAlias = 'My.PickerDataSource.Collection'; + +test.beforeEach(async ({umbracoUi}) => { + await umbracoUi.goToBackOffice(); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can create empty content with an entity picker using the collection data source', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, collectionDataSourceAlias); + await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + 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.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values).toEqual([]); +}); + +test('can create content with an entity picker using the collection data source that has an item', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, collectionDataSourceAlias); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 1'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values[0].value.ids[0]).toEqual('1'); +}); + +test('can create content with an entity picker using the collection data source that has multiple items', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, collectionDataSourceAlias); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 1'); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 3'); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 5'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values[0].value.ids[0]).toEqual('1'); + expect(contentData.values[0].value.ids[1]).toEqual('3'); + expect(contentData.values[0].value.ids[2]).toEqual('5'); +}); + +test('can not create content with an entity picker using the collection data source that has more items than max amount', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataTypeWithMinAndMaxValues(dataTypeName, collectionDataSourceAlias, 0, 2); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 1'); + await umbracoUi.content.isChooseButtonVisible(true); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 3'); + + // Assert + // The choose button should be disabled when the max amount is reached + await umbracoUi.content.isChooseButtonVisible(false); +}); + +test('can not create content with an entity picker using the collection data source that has less items than min amount', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Published'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataTypeWithMinAndMaxValues(dataTypeName, collectionDataSourceAlias, 2, 5); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 1'); + await umbracoUi.content.isTextWithExactNameVisible('This field need more items'); + await umbracoUi.content.clickSaveAndPublishButton(); + await umbracoUi.content.doesErrorNotificationHaveText(NotificationConstantHelper.error.documentCouldNotBePublished); + await umbracoUi.content.chooseCollectionMenuItemWithName('Example 3'); + + // Assert + await umbracoUi.content.clickSaveAndPublishButton(); + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerTree.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerTree.spec.ts new file mode 100644 index 000000000000..494974c511fa --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/Content/EntityPickerTree.spec.ts @@ -0,0 +1,127 @@ +import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const dataTypeName = 'EntityPickerWithTree'; +const treeDataSourceAlias = 'My.PickerDataSource.Tree'; + +test.beforeEach(async ({umbracoUi}) => { + await umbracoUi.goToBackOffice(); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can create empty content with an entity picker using the tree data source', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, treeDataSourceAlias); + await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + 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.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values).toEqual([]); +}); + +test('can create content with an entity picker using the tree data source that has an item', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, treeDataSourceAlias); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 1'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values[0].value.ids[0]).toEqual('1'); +}); + +test('can create content with an entity picker using the tree data source that has multiple items', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, treeDataSourceAlias); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 1'); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 3'); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 5', ['Example Folder 1']); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); + expect(contentData.values[0].value.ids[0]).toEqual('1'); + expect(contentData.values[0].value.ids[1]).toEqual('3'); + expect(contentData.values[0].value.ids[2]).toEqual('5'); +}); + +test('can not create content with an entity picker using the tree data source that has more items than max amount', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataTypeWithMinAndMaxValues(dataTypeName, treeDataSourceAlias, 0, 2); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 1'); + await umbracoUi.content.isChooseButtonVisible(true); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 5', ['Example Folder 1']); + + // Assert + // The choose button should be disabled when the max amount is reached + await umbracoUi.content.isChooseButtonVisible(false); +}); + +test('can not create content with an entity picker using the tree data source that has less items than min amount', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Published'; + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataTypeWithMinAndMaxValues(dataTypeName, treeDataSourceAlias, 2, 5); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 1'); + await umbracoUi.content.isTextWithExactNameVisible('This field need more items'); + await umbracoUi.content.clickSaveAndPublishButton(); + await umbracoUi.content.doesErrorNotificationHaveText(NotificationConstantHelper.error.documentCouldNotBePublished); + await umbracoUi.content.chooseTreeMenuItemWithName('Example 5', ['Example Folder 1']); + + // Assert + await umbracoUi.content.clickSaveAndPublishButton(); + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerCollection.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerCollection.spec.ts new file mode 100644 index 000000000000..1096ebdb4f7a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerCollection.spec.ts @@ -0,0 +1,33 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const dataTypeName = 'EntityPickerWithCollection'; + +test.beforeEach(async ({umbracoUi, umbracoApi}) => { + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSection(ConstantHelper.sections.settings); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can create an entity picker data type with the collection data source', async ({umbracoApi, umbracoUi}) => { + // Act + await umbracoUi.dataType.clickActionsMenuForName('Data Types'); + await umbracoUi.dataType.clickCreateActionMenuOption(); + await umbracoUi.dataType.clickDataTypeButton(); + await umbracoUi.dataType.enterDataTypeName(dataTypeName); + await umbracoUi.dataType.clickSelectAPropertyEditorButton(); + await umbracoUi.dataType.selectAPropertyEditor('Entity Data Picker'); + await umbracoUi.dataType.clickChooseDataSourceButton(); + await umbracoUi.dataType.clickButtonWithName('My Picker Collection Data Source'); + await umbracoUi.dataType.clickChooseModalButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.dataType.isDataTypeTreeItemVisible(dataTypeName); + expect(await umbracoApi.dataType.doesNameExist(dataTypeName)).toBeTruthy(); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerTree.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerTree.spec.ts new file mode 100644 index 000000000000..90c04b5e9505 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/DataType/EntityPickerTree.spec.ts @@ -0,0 +1,33 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const dataTypeName = 'EntityPickerWithTree'; + +test.beforeEach(async ({umbracoUi, umbracoApi}) => { + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSection(ConstantHelper.sections.settings); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can create an entity picker data type with tree data source', async ({umbracoApi, umbracoUi}) => { + // Act + await umbracoUi.dataType.clickActionsMenuForName('Data Types'); + await umbracoUi.dataType.clickCreateActionMenuOption(); + await umbracoUi.dataType.clickDataTypeButton(); + await umbracoUi.dataType.enterDataTypeName(dataTypeName); + await umbracoUi.dataType.clickSelectAPropertyEditorButton(); + await umbracoUi.dataType.selectAPropertyEditor('Entity Data Picker'); + await umbracoUi.dataType.clickChooseDataSourceButton(); + await umbracoUi.dataType.clickButtonWithName('My Picker Tree Data Source'); + await umbracoUi.dataType.clickChooseModalButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.waitForDataTypeToBeCreated(); + await umbracoUi.dataType.isDataTypeTreeItemVisible(dataTypeName); + expect(await umbracoApi.dataType.doesNameExist(dataTypeName)).toBeTruthy(); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerCollection.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerCollection.spec.ts new file mode 100644 index 000000000000..824d22b1d2ab --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerCollection.spec.ts @@ -0,0 +1,39 @@ +import {AliasHelper, test} from '@umbraco/playwright-testhelpers'; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const templateName = 'EntityPickerCollectionTemplate'; +const dataTypeName = 'EntityPickerWithCollection'; +const propertyName = 'TestProperty'; +const collectionDataSourceAlias = 'My.PickerDataSource.Collection'; + +// Ids for Example 4 and Example 2 +const items = {ids: ['4', '2']}; + +test.beforeEach(async ({umbracoUi}) => { + await umbracoUi.goToBackOffice(); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.template.ensureNameNotExists(templateName); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can render content with an entity picker using the collection data source', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, collectionDataSourceAlias); + const templateId = await umbracoApi.template.createTemplateWithEntityDataPickerValue(templateName, propertyName); + const contentKey = await umbracoApi.document.createPublishedDocumentWithValue(contentName, items, dataTypeId, templateId, propertyName, documentTypeName); + const contentURL = await umbracoApi.document.getDocumentUrl(contentKey); + + // Act + await umbracoUi.contentRender.navigateToRenderedContentPage(contentURL); + + // Assert + await umbracoUi.contentRender.doesDataSourceRenderValueHaveText(collectionDataSourceAlias); + for (const value of items.ids) { + await umbracoUi.contentRender.doesContentRenderValueContainText(value); + } +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerTree.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerTree.spec.ts new file mode 100644 index 000000000000..192a0f8dd3e9 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/EntityDataPicker/RenderedContent/EntityPickerTree.spec.ts @@ -0,0 +1,39 @@ +import {AliasHelper, test} from '@umbraco/playwright-testhelpers'; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const templateName = 'EntityPickerTreeTemplate'; +const dataTypeName = 'EntityPickerWithTree'; +const propertyName = 'TestProperty'; +const treeDataSourceAlias = 'My.PickerDataSource.Tree'; + +// Ids for Example 4 and Example 2 +const items = {ids: ['4', '2']}; + +test.beforeEach(async ({umbracoUi}) => { + await umbracoUi.goToBackOffice(); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.template.ensureNameNotExists(templateName); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('can render content with an entity picker using the tree data source', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeId = await umbracoApi.dataType.createEntityDataPickerDataType(dataTypeName, treeDataSourceAlias); + const templateId = await umbracoApi.template.createTemplateWithEntityDataPickerValue(templateName, propertyName); + const contentKey = await umbracoApi.document.createPublishedDocumentWithValue(contentName, items, dataTypeId, templateId, propertyName, documentTypeName); + const contentURL = await umbracoApi.document.getDocumentUrl(contentKey); + + // Act + await umbracoUi.contentRender.navigateToRenderedContentPage(contentURL); + + // Assert + await umbracoUi.contentRender.doesDataSourceRenderValueHaveText(treeDataSourceAlias); + for (const value of items.ids) { + await umbracoUi.contentRender.doesContentRenderValueContainText(value); + } +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js new file mode 100644 index 000000000000..f275d6e50251 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js @@ -0,0 +1,43 @@ +import { LitElement as c, html as m, css as h, property as a, customElement as u } from "@umbraco-cms/backoffice/external/lit"; +import { UmbElementMixin as b } from "@umbraco-cms/backoffice/element-api"; +var d = Object.defineProperty, f = Object.getOwnPropertyDescriptor, l = (p, o, s, r) => { + for (var e = r > 1 ? void 0 : r ? f(o, s) : o, i = p.length - 1, n; i >= 0; i--) + (n = p[i]) && (e = (r ? n(o, s, e) : n(e)) || e); + return r && e && d(o, s, e), e; +}; +let t = class extends b(c) { + render() { + return m` +
My Custom View
+

Heading and Theme: ${this.content?.heading} - ${this.settings?.theme}

+ `; + } +}; +t.styles = [ + h` + :host { + display: block; + height: 100%; + box-sizing: border-box; + background-color: darkgreen; + color: white; + border-radius: 9px; + padding: 12px; + } + ` +]; +l([ + a({ attribute: !1 }) +], t.prototype, "content", 2); +l([ + a({ attribute: !1 }) +], t.prototype, "settings", 2); +t = l([ + u("block-custom-view") +], t); +const w = t; +export { + t as BlockCustomView, + w as default +}; +//# sourceMappingURL=block-custom-view.js.map diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js.map b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js.map new file mode 100644 index 000000000000..48adaed1164a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-custom-view.js.map @@ -0,0 +1 @@ +{"version":3,"file":"block-custom-view.js","sources":["../../block-custom-view/src/block-custom-view.ts"],"sourcesContent":["import { html, customElement, LitElement, property, css } from '@umbraco-cms/backoffice/external/lit';\nimport { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';\nimport type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';\nimport type { UmbBlockEditorCustomViewElement } from '@umbraco-cms/backoffice/block-custom-view';\n\n@customElement('block-custom-view')\nexport class BlockCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewElement {\n\t\n\t@property({ attribute: false })\n\tcontent?: UmbBlockDataType;\n\n\t@property({ attribute: false })\n\tsettings?: UmbBlockDataType;\n\n\trender() {\n\t\treturn html`\n\t\t\t
My Custom View
\n\t\t\t

Heading and Theme: ${this.content?.heading} - ${this.settings?.theme}

\n\t\t`;\n\t}\n\n\tstatic styles = [\n\t\tcss`\n\t\t\t:host {\n\t\t\t\tdisplay: block;\n\t\t\t\theight: 100%;\n\t\t\t\tbox-sizing: border-box;\n\t\t\t\tbackground-color: darkgreen;\n\t\t\t\tcolor: white;\n\t\t\t\tborder-radius: 9px;\n\t\t\t\tpadding: 12px;\n\t\t\t}\n\t\t`,\n\t];\n\t\n}\nexport default BlockCustomView;\n\ndeclare global {\n\tinterface HTMLElementTagNameMap {\n\t\t'block-custom-view': BlockCustomView;\n\t}\n}\n"],"names":["BlockCustomView","UmbElementMixin","LitElement","html","css","__decorateClass","property","customElement","BlockCustomView$1"],"mappings":";;;;;;;AAMO,IAAMA,IAAN,cAA8BC,EAAgBC,CAAU,EAA6C;AAAA,EAQ3G,SAAS;AACR,WAAOC;AAAA;AAAA,2BAEkB,KAAK,SAAS,OAAO,MAAM,KAAK,UAAU,KAAK;AAAA;AAAA,EAEzE;AAgBD;AA7BaH,EAeL,SAAS;AAAA,EACfI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWD;AAxBAC,EAAA;AAAA,EADCC,EAAS,EAAE,WAAW,GAAA,CAAO;AAAA,GAFlBN,EAGZ,WAAA,WAAA,CAAA;AAGAK,EAAA;AAAA,EADCC,EAAS,EAAE,WAAW,GAAA,CAAO;AAAA,GALlBN,EAMZ,WAAA,YAAA,CAAA;AANYA,IAANK,EAAA;AAAA,EADNE,EAAc,mBAAmB;AAAA,GACrBP,CAAA;AA8Bb,MAAAQ,IAAeR;"} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js new file mode 100644 index 000000000000..30af1b6993b6 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js @@ -0,0 +1,23 @@ +import { LitElement as n, html as c, customElement as u } from "@umbraco-cms/backoffice/external/lit"; +import { UmbElementMixin as a } from "@umbraco-cms/backoffice/element-api"; +var d = Object.getOwnPropertyDescriptor, p = (o, l, i, m) => { + for (var e = m > 1 ? void 0 : m ? d(l, i) : l, t = o.length - 1, s; t >= 0; t--) + (s = o[t]) && (e = s(e) || e); + return e; +}; +let r = class extends a(n) { + render() { + return c` +
Block Grid Custom View
+ `; + } +}; +r = p([ + u("block-grid-custom-view") +], r); +const f = r; +export { + r as BlockGridCustomView, + f as default +}; +//# sourceMappingURL=block-grid-custom-view.js.map diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js.map b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js.map new file mode 100644 index 000000000000..d918c3052096 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/block-grid-custom-view.js.map @@ -0,0 +1 @@ +{"version":3,"file":"block-grid-custom-view.js","sources":["../../block-custom-view/src/block-grid-custom-view.ts"],"sourcesContent":["import { html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit';\nimport { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';\nimport type { UmbBlockEditorCustomViewElement }\r\nfrom '@umbraco-cms/backoffice/block-custom-view';\n\n@customElement('block-grid-custom-view')\nexport class BlockGridCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewElement\r\n{\r\n render() {\r\n return html`\n
Block Grid Custom View
\n\t\t`;\r\n }\n\t\n}\nexport default BlockGridCustomView;\n\ndeclare global\r\n{\n\r\n interface HTMLElementTagNameMap\r\n{\n\t\t'block-grid-custom-view': BlockGridCustomView;\n\t}\n}\n"],"names":["BlockGridCustomView","UmbElementMixin","LitElement","html","__decorateClass","customElement","BlockGridCustomView$1"],"mappings":";;;;;;;AAMO,IAAMA,IAAN,cAAkCC,EAAgBC,CAAU,EACnE;AAAA,EACI,SAAS;AACL,WAAOC;AAAA;AAAA;AAAA,EAGX;AAEJ;AARaH,IAANI,EAAA;AAAA,EADNC,EAAc,wBAAwB;AAAA,GAC1BL,CAAA;AASb,MAAAM,IAAeN;"} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/umbraco-package.json new file mode 100644 index 000000000000..d332f18297ab --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/block-custom-view/umbraco-package.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../umbraco-package-schema.json", + "name": "My.CustomViewPackage", + "version": "0.1.0", + "extensions": [ + { + "type": "blockEditorCustomView", + "alias": "my.blockEditorCustomView.Example", + "name": "My Example Custom View", + "element": "/App_Plugins/block-custom-view/block-custom-view.js", + "forContentTypeAlias": "elementTypeForCustomBlockView" + }, + { + "type": "blockEditorCustomView", + "alias": "my.blockGridCustomView.Example", + "name": "My Block Grid Custom View", + "element": "/App_Plugins/block-custom-view/block-grid-custom-view.js", + "forBlockEditor": "block-grid" + } + ] +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js new file mode 100644 index 000000000000..0629033aef97 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js @@ -0,0 +1,106 @@ +import { css as u, state as d, customElement as _, html as n } from "@umbraco-cms/backoffice/external/lit"; +import { UmbLitElement as f } from "@umbraco-cms/backoffice/lit-element"; +import { UMB_DOCUMENT_COLLECTION_CONTEXT as v } from "@umbraco-cms/backoffice/document"; +var y = Object.defineProperty, w = Object.getOwnPropertyDescriptor, h = (t) => { + throw TypeError(t); +}, m = (t, e, a, s) => { + for (var i = s > 1 ? void 0 : s ? w(e, a) : e, o = t.length - 1, l; o >= 0; o--) + (l = t[o]) && (i = (s ? l(e, a, i) : l(i)) || i); + return s && i && y(e, a, i), i; +}, b = (t, e, a) => e.has(t) || h("Cannot " + a), C = (t, e, a) => e.has(t) ? h("Cannot add the same private member more than once") : e instanceof WeakSet ? e.add(t) : e.set(t, a), E = (t, e, a) => (b(t, e, "access private method"), a), c, p; +let r = class extends f { + constructor() { + super(), C(this, c), this._columns = [], this._items = [], this.consumeContext(v, (t) => { + t?.setupView(this), this.observe(t?.userDefinedProperties, (e) => { + E(this, c, p).call(this, e); + }), this.observe(t?.items, (e) => { + this._items = e; + }); + }); + } + render() { + return this._items === void 0 ? n`

Not found...

` : n` + + + + ${this._columns.map((t) => n``)} + + + + ${this._items.map( + (t) => n` + + ${this._columns.map((e) => { + switch (e.alias) { + case "name": + return n``; + case "entityActions": + return n``; + default: + const a = t.values.find((s) => s.alias === e.alias)?.value ?? ""; + return n``; + } + })} + + ` + )} + +
${t.name}
${t.variants[0].name}${a}
+ `; + } +}; +c = /* @__PURE__ */ new WeakSet(); +p = function(t = []) { + const e = [ + { name: "Name", alias: "name" }, + { name: "State", alias: "state" } + ], a = t.map((s) => ({ + name: s.nameTemplate ?? s.alias, + alias: s.alias + })); + this._columns = [...e, ...a, { name: "", alias: "entityActions", align: "right" }]; +}; +r.styles = u` + :host { + display: block; + width: 100%; + overflow-x: auto; + font-family: sans-serif; + } + table { + width: 100%; + border-collapse: collapse; + } + th, + td { + padding: 6px 10px; + border: 1px solid #ddd; + white-space: nowrap; + } + th { + background: #f8f8f8; + font-weight: 600; + } + a { + color: var(--uui-color-interactive, #0366d6); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + `; +m([ + d() +], r.prototype, "_columns", 2); +m([ + d() +], r.prototype, "_items", 2); +r = m([ + _("my-document-table-collection-view") +], r); +const O = r; +export { + r as MyDocumentTableCollectionViewElement, + O as default +}; +//# sourceMappingURL=my-collection-view.js.map diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js.map b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js.map new file mode 100644 index 000000000000..1b84b4da576a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/my-collection-view.js.map @@ -0,0 +1 @@ +{"version":3,"file":"my-collection-view.js","sources":["../../my-collection-view/src/my-collection-view.ts"],"sourcesContent":["import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit';\r\nimport { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';\r\nimport { UMB_DOCUMENT_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/document';\r\nimport type { UmbDocumentCollectionItemModel } from '@umbraco-cms/backoffice/document';\r\nimport type { UmbCollectionColumnConfiguration } from '@umbraco-cms/backoffice/collection';\r\n\r\n@customElement('my-document-table-collection-view')\r\nexport class MyDocumentTableCollectionViewElement extends UmbLitElement {\r\n\r\n\t@state() private _columns: Array<{ name: string; alias: string; align?: string }> = [];\r\n\t@state() private _items?: Array = [];\r\n\r\n\tconstructor() {\r\n\t\tsuper();\r\n\r\n\t\tthis.consumeContext(UMB_DOCUMENT_COLLECTION_CONTEXT, (collectionContext) => {\r\n\t\t\tcollectionContext?.setupView(this);\r\n\r\n\t\t\tthis.observe(collectionContext?.userDefinedProperties, (props) => {\r\n\t\t\t\tthis.#createColumns(props);\r\n\t\t\t});\r\n\r\n\t\t\tthis.observe(collectionContext?.items, (items) => {\r\n\t\t\t\tthis._items = items;\r\n\t\t\t});\r\n\t\t});\r\n\t}\r\n\r\n\t#createColumns(userProps: Array = []) {\r\n\t\tconst baseCols = [\r\n\t\t\t{ name: 'Name', alias: 'name' },\r\n\t\t\t{ name: 'State', alias: 'state' },\r\n\t\t];\r\n\t\tconst userCols = userProps.map((p) => ({\r\n\t\t\tname: p.nameTemplate ?? p.alias,\r\n\t\t\talias: p.alias,\r\n\t\t}));\r\n\t\tthis._columns = [...baseCols, ...userCols, { name: '', alias: 'entityActions', align: 'right' }];\r\n\t}\r\n\r\n\toverride render() {\r\n if (this._items === undefined) return html`

Not found...

`;\r\n\t\treturn html`\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\t\r\n\t\t\t\t\t\t${this._columns.map((col) => html``)}\r\n\t\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\t${this._items.map(\r\n\t\t\t(item) => html`\r\n\t\t\t\t\t\t\t\r\n\t\t\t\t\t\t\t\t${this._columns.map((col) => {\r\n\t\t\t\tswitch (col.alias) {\r\n\t\t\t\t\tcase 'name':\r\n\t\t\t\t\t\treturn html``;\r\n\t\t\t\t\tcase 'entityActions':\r\n\t\t\t\t\t\treturn html``;\r\n\t\t\t\t\tdefault:\r\n\t\t\t\t\t\tconst val = item.values.find((v) => v.alias === col.alias)?.value ?? '';\r\n\t\t\t\t\t\treturn html``;\r\n\t\t\t\t}\r\n\t\t\t})}\r\n\t\t\t\t\t\t\t\r\n\t\t\t\t\t\t`\r\n\t\t)}\r\n\t\t\t\t\r\n\t\t\t
${col.name}
${item.variants[0].name}${val}
\r\n\t\t`;\r\n\t}\r\n\r\n\tstatic override styles = css`\r\n\t\t:host {\r\n\t\t\tdisplay: block;\r\n\t\t\twidth: 100%;\r\n\t\t\toverflow-x: auto;\r\n\t\t\tfont-family: sans-serif;\r\n\t\t}\r\n\t\ttable {\r\n\t\t\twidth: 100%;\r\n\t\t\tborder-collapse: collapse;\r\n\t\t}\r\n\t\tth,\r\n\t\ttd {\r\n\t\t\tpadding: 6px 10px;\r\n\t\t\tborder: 1px solid #ddd;\r\n\t\t\twhite-space: nowrap;\r\n\t\t}\r\n\t\tth {\r\n\t\t\tbackground: #f8f8f8;\r\n\t\t\tfont-weight: 600;\r\n\t\t}\r\n\t\ta {\r\n\t\t\tcolor: var(--uui-color-interactive, #0366d6);\r\n\t\t\ttext-decoration: none;\r\n\t\t}\r\n\t\ta:hover {\r\n\t\t\ttext-decoration: underline;\r\n\t\t}\r\n\t`;\r\n}\r\n\r\nexport default MyDocumentTableCollectionViewElement;\r\n\r\ndeclare global {\r\n\tinterface HTMLElementTagNameMap {\r\n\t\t'my-document-table-collection-view': MyDocumentTableCollectionViewElement;\r\n\t}\r\n}\r\n"],"names":["_MyDocumentTableCollectionViewElement_instances","createColumns_fn","MyDocumentTableCollectionViewElement","UmbLitElement","__privateAdd","UMB_DOCUMENT_COLLECTION_CONTEXT","collectionContext","props","__privateMethod","items","html","col","item","val","v","userProps","baseCols","userCols","p","css","__decorateClass","state","customElement","MyDocumentTableCollectionViewElement$1"],"mappings":";;;;;;;;;8OAAAA,GAAAC;AAOO,IAAMC,IAAN,cAAmDC,EAAc;AAAA,EAKvE,cAAc;AACb,UAAA,GANKC,EAAA,MAAAJ,CAAA,GAEG,KAAQ,WAAmE,CAAA,GAC3E,KAAQ,SAAiD,CAAA,GAKjE,KAAK,eAAeK,GAAiC,CAACC,MAAsB;AAC3E,MAAAA,GAAmB,UAAU,IAAI,GAEjC,KAAK,QAAQA,GAAmB,uBAAuB,CAACC,MAAU;AACjE,QAAAC,EAAA,MAAKR,MAAL,KAAA,MAAoBO,CAAA;AAAA,MACrB,CAAC,GAED,KAAK,QAAQD,GAAmB,OAAO,CAACG,MAAU;AACjD,aAAK,SAASA;AAAA,MACf,CAAC;AAAA,IACF,CAAC;AAAA,EACF;AAAA,EAcS,SAAS;AACX,WAAI,KAAK,WAAW,SAAkBC,yBACrCA;AAAA;AAAA;AAAA;AAAA,QAID,KAAK,SAAS,IAAI,CAACC,MAAQD,0BAA6BC,EAAI,SAAS,MAAM,KAAKA,EAAI,IAAI,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA,OAIjG,KAAK,OAAO;AAAA,MAChB,CAACC,MAASF;AAAA;AAAA,UAEH,KAAK,SAAS,IAAI,CAACC,MAAQ;AACjC,gBAAQA,EAAI,OAAA;AAAA,UACX,KAAK;AACJ,mBAAOD,oBAAuBE,EAAK,SAAS,CAAC,EAAE,IAAI;AAAA,UACpD,KAAK;AACJ,mBAAOF;AAAA,UACR;AACC,kBAAMG,IAAMD,EAAK,OAAO,KAAK,CAACE,MAAMA,EAAE,UAAUH,EAAI,KAAK,GAAG,SAAS;AACrE,mBAAOD,QAAWG,CAAG;AAAA,QAAA;AAAA,MAExB,CAAC,CAAC;AAAA;AAAA;AAAA,IAAA,CAGF;AAAA;AAAA;AAAA;AAAA,EAIF;AA+BD;AA9FOb,IAAA,oBAAA,QAAA;AAqBNC,IAAc,SAACc,IAAqD,IAAI;AACvE,QAAMC,IAAW;AAAA,IAChB,EAAE,MAAM,QAAQ,OAAO,OAAA;AAAA,IACvB,EAAE,MAAM,SAAS,OAAO,QAAA;AAAA,EAAQ,GAE3BC,IAAWF,EAAU,IAAI,CAACG,OAAO;AAAA,IACtC,MAAMA,EAAE,gBAAgBA,EAAE;AAAA,IAC1B,OAAOA,EAAE;AAAA,EAAA,EACR;AACF,OAAK,WAAW,CAAC,GAAGF,GAAU,GAAGC,GAAU,EAAE,MAAM,IAAI,OAAO,iBAAiB,OAAO,SAAS;AAChG;AA/BYf,EAiEI,SAASiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA/DRC,EAAA;AAAA,EAAhBC,EAAA;AAAM,GAFKnB,EAEK,WAAA,YAAA,CAAA;AACAkB,EAAA;AAAA,EAAhBC,EAAA;AAAM,GAHKnB,EAGK,WAAA,UAAA,CAAA;AAHLA,IAANkB,EAAA;AAAA,EADNE,EAAc,mCAAmC;AAAA,GACrCpB,CAAA;AAgGb,MAAAqB,IAAerB;"} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/umbraco-package.json new file mode 100644 index 000000000000..1cef3bf27535 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-collection-view/umbraco-package.json @@ -0,0 +1,24 @@ +{ + "$schema": "../../umbraco-package-schema.json", + "name": "My Collection View", + "version": "0.1.0", + "extensions": [ + { + "type": "collectionView", + "alias": "My.CollectionView.Document.Table", + "name": "My Collection View Table", + "element": "/App_Plugins/my-collection-view/my-collection-view.js", + "meta": { + "label": "Table", + "icon": "icon-list", + "pathName": "table" + }, + "conditions": [ + { + "alias": "Umb.Condition.CollectionAlias", + "match": "Umb.Collection.Document" + } + ] + } + ] +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/my-property-action.manifests.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/my-property-action.manifests.js new file mode 100644 index 000000000000..c381dc8ce6f6 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/my-property-action.manifests.js @@ -0,0 +1,28 @@ +export const manifests = [ + { + type: 'propertyAction', + kind: 'default', + alias: 'My.propertyAction.Write', + name: 'Write Property Action ', + forPropertyEditorUis: ["Umb.PropertyEditorUi.TextBox"], + api: () => import('./write-property-action.api.js'), + weight: 200, + meta: { + icon: 'icon-brush', + label: 'Write text', + } + }, + { + type: 'propertyAction', + kind: 'default', + alias: 'My.propertyAction.Read', + name: 'Read Property Action ', + forPropertyEditorUis: ["Umb.PropertyEditorUi.TextBox"], + api: () => import('./read-property-action.api.js'), + weight: 200, + meta: { + icon: 'icon-eye', + label: 'Read text', + } + } +] diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/read-property-action.api.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/read-property-action.api.js new file mode 100644 index 000000000000..6d093f3cdbca --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/read-property-action.api.js @@ -0,0 +1,20 @@ +import { UmbPropertyActionBase } from '@umbraco-cms/backoffice/property-action'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +export class ReadPropertyAction extends UmbPropertyActionBase { + async execute() { + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + if (!propertyContext) { + return; + } + const value = propertyContext.getValue(); + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + notificationContext?.peek('positive', { + data: { + headline: '', + message: value, + }, + }); + } +} +export { ReadPropertyAction as api }; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/umbraco-package.json new file mode 100644 index 000000000000..b5bc3e6849ae --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/umbraco-package.json @@ -0,0 +1,12 @@ +{ + "name": "E2E Test Package", + "version": "1.0.0", + "extensions": [ + { + "type": "bundle", + "alias": "My.PropertyAction.Bundle", + "name": "My property action bundle", + "js": "/App_Plugins/my-property-action/my-property-action.manifests.js" + } + ] +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/write-property-action.api.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/write-property-action.api.js new file mode 100644 index 000000000000..e6f0c9a64a65 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/my-property-action/write-property-action.api.js @@ -0,0 +1,13 @@ +import { UmbPropertyActionBase } from '@umbraco-cms/backoffice/property-action'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +export class WritePropertyAction extends UmbPropertyActionBase { + async execute() { + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + if (!propertyContext) { + return; + } + propertyContext.setValue('Hello world'); + + } +} +export { WritePropertyAction as api }; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/umbraco-package.json new file mode 100644 index 000000000000..d8e628762cd2 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/umbraco-package.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../umbraco-package-schema.json", + "name": "My.WelcomePackage", + "version": "0.1.0", + "extensions": [ + { + "type": "dashboard", + "alias": "my.welcome.dashboard", + "name": "My Welcome Dashboard", + "element": "/App_Plugins/welcome-dashboard/welcome-dashboard.js", + "elementName": "my-welcome-dashboard", + "weight": 30, + "meta": { + "label": "Welcome Dashboard", + "pathname": "welcome-dashboard" + }, + "conditions": [ + { + "alias": "Umb.Condition.SectionAlias", + "match": "Umb.Section.Content" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js new file mode 100644 index 000000000000..8ddf0fbaa3a0 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js @@ -0,0 +1,38 @@ +import { css as n, customElement as c, html as d } from "@umbraco-cms/backoffice/external/lit"; +import { UmbLitElement as p } from "@umbraco-cms/backoffice/lit-element"; +var i = Object.getOwnPropertyDescriptor, h = (r, s, l, a) => { + for (var e = a > 1 ? void 0 : a ? i(s, l) : s, o = r.length - 1, m; o >= 0; o--) + (m = r[o]) && (e = m(e) || e); + return e; +}; +let t = class extends p { + render() { + return d` +

Welcome Dashboard

+
+

+ This is the Backoffice. From here, you can modify the content, + media, and settings of your website. +

+

© Sample Company 20XX

+
+ `; + } +}; +t.styles = [ + n` + :host { + display: block; + padding: 24px; + } + ` +]; +t = h([ + c("my-welcome-dashboard") +], t); +const b = t; +export { + t as MyWelcomeDashboardElement, + b as default +}; +//# sourceMappingURL=welcome-dashboard.js.map diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js.map b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js.map new file mode 100644 index 000000000000..65539b9a6582 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/welcome-dashboard/welcome-dashboard.js.map @@ -0,0 +1 @@ +{"version":3,"file":"welcome-dashboard.js","sources":["../../welcome-dashboard/src/welcome-dashboard.element.ts"],"sourcesContent":["import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';\r\nimport { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';\r\n\r\n@customElement('my-welcome-dashboard')\r\nexport class MyWelcomeDashboardElement extends UmbLitElement {\r\n\r\n override render() {\r\n return html`\r\n

Welcome Dashboard

\r\n
\r\n

\r\n This is the Backoffice. From here, you can modify the content,\r\n media, and settings of your website.\r\n

\r\n

© Sample Company 20XX

\r\n
\r\n `;\r\n }\r\n\r\n static override readonly styles = [\r\n css`\r\n :host {\r\n display: block;\r\n padding: 24px;\r\n }\r\n `,\r\n ];\r\n}\r\n\r\nexport default MyWelcomeDashboardElement;\r\n\r\ndeclare global {\r\n interface HTMLElementTagNameMap {\r\n 'my-welcome-dashboard': MyWelcomeDashboardElement;\r\n }\r\n}"],"names":["MyWelcomeDashboardElement","UmbLitElement","html","css","__decorateClass","customElement","MyWelcomeDashboardElement$1"],"mappings":";;;;;;;AAIO,IAAMA,IAAN,cAAwCC,EAAc;AAAA,EAEhD,SAAS;AACd,WAAOC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUX;AAUJ;AAvBaF,EAegB,SAAS;AAAA,EAC9BG;AAAA;AAAA;AAAA;AAAA;AAAA;AAMJ;AAtBSH,IAANI,EAAA;AAAA,EADNC,EAAc,sBAAsB;AAAA,GACxBL,CAAA;AAyBb,MAAAM,IAAeN;"} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/umbraco-package.json new file mode 100644 index 000000000000..42d4c1957f7a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/umbraco-package.json @@ -0,0 +1,24 @@ +{ + "$schema": "../../umbraco-package-schema.json", + "name": "My workspace", + "version": "0.1.0", + "extensions": [ + { + "type": "workspaceView", + "alias": "My.WorkspaceView", + "name": "My Workspace View", + "element": "/App_Plugins/workspace-view/workspace-view.js", + "meta": { + "label": "My Workspace View", + "pathname": "/my-workspace-view", + "icon": "icon-add" + }, + "conditions": [ + { + "alias": "Umb.Condition.WorkspaceAlias", + "match": "Umb.Workspace.Document" + } + ] + } + ] +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js new file mode 100644 index 000000000000..b471d1e7cf1a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js @@ -0,0 +1,28 @@ +import { LitElement as n, html as a, css as c, customElement as p } from "@umbraco-cms/backoffice/external/lit"; +import { UmbElementMixin as u } from "@umbraco-cms/backoffice/element-api"; +var w = Object.getOwnPropertyDescriptor, v = (o, s, i, l) => { + for (var e = l > 1 ? void 0 : l ? w(s, i) : s, r = o.length - 1, m; r >= 0; r--) + (m = o[r]) && (e = m(e) || e); + return e; +}; +let t = class extends u(n) { + render() { + return a` + + Welcome to my newly created workspace view. + + `; + } +}; +t.styles = c` + uui-box { + margin: 20px; + } + `; +t = v([ + p("my-workspaceview") +], t); +export { + t as default +}; +//# sourceMappingURL=workspace-view.js.map diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js.map b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js.map new file mode 100644 index 000000000000..42be166b3e3f --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/AdditionalSetup/App_Plugins/workspace-view/workspace-view.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workspace-view.js","sources":["../../workspace-view/src/my-element.ts"],"sourcesContent":["import { LitElement, html, customElement, css } from \"@umbraco-cms/backoffice/external/lit\";\nimport { UmbElementMixin } from \"@umbraco-cms/backoffice/element-api\";\n\n@customElement('my-workspaceview')\nexport default class MyWorkspaceViewElement extends UmbElementMixin(LitElement) {\n\n render() {\n return html` \n \n Welcome to my newly created workspace view.\n \n `\n }\n\n static styles = css`\n uui-box {\n margin: 20px;\n }\n `\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'my-workspaceview': MyWorkspaceViewElement\n }\n}\n"],"names":["MyWorkspaceViewElement","UmbElementMixin","LitElement","html","css","__decorateClass","customElement"],"mappings":";;;;;;;AAIA,IAAqBA,IAArB,cAAoDC,EAAgBC,CAAU,EAAE;AAAA,EAE5E,SAAS;AACL,WAAOC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKX;AAOJ;AAfqBH,EAUV,SAASI;AAAA;AAAA;AAAA;AAAA;AAVCJ,IAArBK,EAAA;AAAA,EADCC,EAAc,kBAAkB;AAAA,GACZN,CAAA;"} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/BlockCustomView.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/BlockCustomView.spec.ts new file mode 100644 index 000000000000..8ef280fdbaa9 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/BlockCustomView.spec.ts @@ -0,0 +1,168 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +// Content +const contentName = 'TestContent'; +const contentGroupName = 'TestContentGroup'; +// DocumentType +const documentTypeName = 'TestDocumentTypeForContent'; +// DataType +const dataTypeName = 'Textstring'; +const textAreaDataTypeName = 'Textarea'; +// BlockType +const blockGridName = 'TestBlockGridForContent'; +const blockListName = 'TestBlockListForContent'; +// ElementType +const elementGroupName = 'TestElementGroupForContent'; +const firstElementTypeName = 'TestElementTypeForContent'; +const secondElementTypeName = 'Element Type For Custom Block View'; +// Setting Model +const settingModelName = 'Test Setting Model'; +const groupName = 'Test Group'; +// Block Custom View +const blockGridCustomViewLocator = 'block-grid-custom-view'; +const blockCustomViewLocator = 'block-custom-view'; +// Property Editor +const propertyEditorName = 'Heading'; +const propertyEditorSettingName = 'Theme'; + +test.afterEach(async ({ umbracoApi }) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.dataType.ensureNameNotExists(blockGridName); + await umbracoApi.dataType.ensureNameNotExists(blockListName); + await umbracoApi.documentType.ensureNameNotExists(firstElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(secondElementTypeName); + await umbracoApi.dataType.ensureNameNotExists(dataTypeName); +}); + +test('block custom view appears in a specific block type', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringDataType = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(firstElementTypeName, elementGroupName, dataTypeName, textStringDataType.id); + const blockGridId = await umbracoApi.dataType.createBlockGridWithABlock(blockGridName, elementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockGridName, blockGridId, contentGroupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockElementWithName(firstElementTypeName); + await umbracoUi.content.clickCreateModalButton(); + + // Assert + await umbracoUi.content.isBlockCustomViewVisible(blockGridCustomViewLocator); + await umbracoUi.content.isSingleBlockElementVisible(false); +}); + +test('block custom view does not appear in block list editor when configured for block grid only', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringDataType = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(firstElementTypeName, elementGroupName, dataTypeName, textStringDataType.id); + const blockListId = await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListName, elementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockListName, blockListId, contentGroupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockElementWithName(firstElementTypeName); + await umbracoUi.content.clickCreateModalButton(); + + // Assert + await umbracoUi.content.isBlockCustomViewVisible(blockGridCustomViewLocator, false); + await umbracoUi.content.isSingleBlockElementVisible(); +}); + +test('block custom view applies to correct content type', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringDataType = await umbracoApi.dataType.getByName(dataTypeName); + const firstElementTypeId = await umbracoApi.documentType.createDefaultElementType(firstElementTypeName, elementGroupName, dataTypeName, textStringDataType.id); + const secondElementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementTypeName, elementGroupName, dataTypeName, textStringDataType.id); + const blockListId = await umbracoApi.dataType.createBlockListDataTypeWithTwoBlocks(blockListName, firstElementTypeId, secondElementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockListName, blockListId, contentGroupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockCardWithName(secondElementTypeName); + await umbracoUi.content.clickCreateModalButton(); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockCardWithName(firstElementTypeName); + await umbracoUi.content.clickCreateModalButton(); + + // Assert + await umbracoUi.content.isBlockCustomViewVisible(blockCustomViewLocator); + await umbracoUi.content.isSingleBlockElementVisible(); +}); + +test('block custom view can display values from the content and settings parts', async ({umbracoApi, umbracoUi}) => { + // Arrange + const contentValue = 'This is block test'; + const settingValue = 'This is setting test'; + const valueText = `Heading and Theme: ${contentValue} - ${settingValue}`; + const textStringDataType = await umbracoApi.dataType.getByName(dataTypeName); + const textAreaDataType = await umbracoApi.dataType.getByName(textAreaDataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementTypeName, elementGroupName, propertyEditorName, textStringDataType.id); + const settingsElementTypeId = await umbracoApi.documentType.createDefaultElementType(settingModelName, groupName, propertyEditorSettingName, textAreaDataType.id); + const blockListId = await umbracoApi.dataType.createBlockListDataTypeWithContentAndSettingsElementType(blockListName, elementTypeId, settingsElementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockListName, blockListId, contentGroupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockCardWithName(secondElementTypeName); + await umbracoUi.content.enterTextstring(contentValue); + await umbracoUi.content.clickAddBlockSettingsTabButton(); + await umbracoUi.content.enterTextArea(settingValue); + await umbracoUi.content.clickCreateModalButton(); + + // Assert + await umbracoUi.content.isBlockCustomViewVisible(blockCustomViewLocator); + await umbracoUi.content.doesBlockCustomViewHaveValue(blockCustomViewLocator, valueText); +}); + +test('block custom view can display values from the content and settings parts after update', async ({umbracoApi, umbracoUi}) => { + // Arrange + const contentValue = 'This is block test'; + const settingValue = 'This is setting test'; + const updatedContentValue = 'This is updated block test'; + const updatedSettingValue = 'This is updated setting test'; + const updatedValueText = `Heading and Theme: ${updatedContentValue} - ${updatedSettingValue}`; + const textStringDataType = await umbracoApi.dataType.getByName(dataTypeName); + const textAreaDataType = await umbracoApi.dataType.getByName(textAreaDataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementTypeName, elementGroupName, propertyEditorName, textStringDataType.id); + const settingsElementTypeId = await umbracoApi.documentType.createDefaultElementType(settingModelName, groupName, propertyEditorSettingName, textAreaDataType.id); + const blockListId = await umbracoApi.dataType.createBlockListDataTypeWithContentAndSettingsElementType(blockListName, elementTypeId, settingsElementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockListName, blockListId, contentGroupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockElementButton(); + await umbracoUi.content.clickBlockCardWithName(secondElementTypeName); + await umbracoUi.content.enterTextstring(contentValue); + await umbracoUi.content.clickAddBlockSettingsTabButton(); + await umbracoUi.content.enterTextArea(settingValue); + await umbracoUi.content.clickCreateModalButton(); + await umbracoUi.content.clickEditBlockListBlockButton(); + await umbracoUi.content.enterTextstring(updatedContentValue); + await umbracoUi.content.clickAddBlockSettingsTabButton(); + await umbracoUi.content.enterTextArea(updatedSettingValue); + await umbracoUi.content.clickUpdateButton(); + + // Assert + await umbracoUi.content.isBlockCustomViewVisible(blockCustomViewLocator); + await umbracoUi.content.doesBlockCustomViewHaveValue(blockCustomViewLocator, updatedValueText); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CollectionView.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CollectionView.spec.ts new file mode 100644 index 000000000000..8ebda107cfef --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CollectionView.spec.ts @@ -0,0 +1,71 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +// Content +const parentContentName = 'parentContentName'; +// DocumentType +const documentTypeParentName = 'ParentDocumentType'; +const documentTypeChildName = 'ChildDocumentType'; +// DataType +const customDataTypeName = 'Custom List View'; +const layoutName = 'My Collection View Table'; +const layoutCollectionView = 'My.CollectionView.Document.Table'; + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(parentContentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeParentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeChildName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can see the custom collection view when choosing layout for new collection data type', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSettingsTreeItem('Data Types'); + await umbracoApi.dataType.createListViewContentDataType(customDataTypeName); + await umbracoUi.dataType.goToDataType(customDataTypeName); + + // Act + await umbracoUi.dataType.addLayouts(layoutName); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessStateVisibleForSaveButton(); + expect(await umbracoApi.dataType.doesListViewHaveLayout(customDataTypeName, layoutName, 'icon-list', layoutCollectionView)).toBeTruthy(); +}); + +test('can see the pagination works when using custom collection view in content section', async ({umbracoApi, umbracoUi}) => { + // Arrange + const pageSize = 5; + const totalItems = 7; + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSettingsTreeItem('Data Types'); + const dataTypeId = await umbracoApi.dataType.createListViewContentDataTypeWithLayoutAndPageSize(customDataTypeName,layoutCollectionView, layoutName, pageSize); + const documentTypeChildId = await umbracoApi.documentType.createDefaultDocumentType(documentTypeChildName); + const documentTypeParentId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNodeAndCollectionId(documentTypeParentName, documentTypeChildId, dataTypeId); + const documentParentId = await umbracoApi.document.createDefaultDocument(parentContentName, documentTypeParentId); + for (let i = 1; i <= totalItems; i++) { + await umbracoApi.document.createDefaultDocumentWithParent('Test child ' + i, documentTypeChildId, documentParentId); + } + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(parentContentName); + + // Assert + // Page 1 + await umbracoUi.content.doesListViewItemsHaveCount(pageSize); + await umbracoUi.content.isListViewItemWithNameVisible('Test child 1', 0); + await umbracoUi.content.isListViewItemWithNameVisible('Test child 5', 4); + // Page 2 + await umbracoUi.content.clickPaginationNextButton(); + await umbracoUi.content.doesListViewItemsHaveCount(2); + await umbracoUi.content.isListViewItemWithNameVisible('Test child 6', 0); + await umbracoUi.content.isListViewItemWithNameVisible('Test child 7', 1); + + // Clean + for (let i = 1; i <= totalItems; i++) { + await umbracoApi.document.ensureNameNotExists('Test child ' + i); + } +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CustomDashboard.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CustomDashboard.spec.ts new file mode 100644 index 000000000000..0303f6b9af38 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/CustomDashboard.spec.ts @@ -0,0 +1,22 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +// Dashboard +const dashboardName = 'Welcome Dashboard'; + +test('can see the custom dashboard in content section', async ({umbracoUi}) => { + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Assert + await umbracoUi.content.isDashboardTabWithNameVisible(dashboardName, true); +}); + +test('can not see the custom dashboard in media section', async ({umbracoUi}) => { + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.media); + + // Assert + await umbracoUi.content.isDashboardTabWithNameVisible(dashboardName, false); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/PropertyAction.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/PropertyAction.spec.ts new file mode 100644 index 000000000000..20422343b520 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/PropertyAction.spec.ts @@ -0,0 +1,59 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import { expect } from '@playwright/test'; + +// Content +const contentName = 'TestContent'; +// DocumentType +const documentTypeName = 'TestDocumentTypeForContent'; +// GroupName +const groupName = 'Content'; +// DataType +const dataTypeName = 'Textstring'; +// Property actions name +const writeActionName = 'Write text'; +const readActionName = 'Read text'; +// Test values +const readTextValue = 'Test text value'; +const writeTextValue = 'Hello world'; + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); +}); + +test('can read value from textstring editor using read property action', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id, groupName); + await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, readTextValue, dataTypeName); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickActionsMenuForProperty(groupName, dataTypeName); + await umbracoUi.content.clickPropertyActionWithName(readActionName); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(readTextValue); +}); + +test('can write value to textstring editor using write property action', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id, groupName); + const contentId = await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, '', dataTypeName); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickActionsMenuForProperty(groupName, dataTypeName); + await umbracoUi.content.clickPropertyActionWithName(writeActionName); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessStateVisibleForSaveButton(); + const updatedContentData = await umbracoApi.document.get(contentId); + expect(updatedContentData.values[0].value).toBe(writeTextValue); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/WorkspaceView.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/WorkspaceView.spec.ts new file mode 100644 index 000000000000..b5d166fe86a6 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExtensionRegistry/WorkspaceView.spec.ts @@ -0,0 +1,44 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +// Content +const contentName = 'TestContent'; +// DocumentType +const documentTypeName = 'TestDocumentTypeForContent'; +// DataType +const dataTypeName = 'Textstring'; +// Media +const mediaName = 'TestMedia'; + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.media.ensureNameNotExists(mediaName); +}); + +test('can see the custom workspace view in the content section', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id); + await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, 'Test content', dataTypeName); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + + // Assert + await umbracoUi.content.isWorkspaceViewTabWithAliasVisible('My.WorkspaceView', true); +}); + +test('cannot see the custom workspace view in the media section', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.media.createDefaultMediaWithImage(mediaName); + + // Act + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.media); + await umbracoUi.media.goToMediaWithName(mediaName); + + // Assert + await umbracoUi.media.isWorkspaceViewTabWithAliasVisible('My.WorkspaceView', false); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs b/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs index 4c87e3ca6436..a030b4c4a800 100644 --- a/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs @@ -129,9 +129,9 @@ public override ITemplate Build() return template; } - public static Template CreateTextPageTemplate(string alias = "textPage") => + public static Template CreateTextPageTemplate(string alias = "textPage", string name = "Text page") => (Template)new TemplateBuilder() .WithAlias(alias) - .WithName("Text page") + .WithName(name) .Build(); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/HealthCheck/ExecuteActionHealthCheckControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/HealthCheck/ExecuteActionHealthCheckControllerTests.cs index 249710ab1ecb..e4dd67d64dc0 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/HealthCheck/ExecuteActionHealthCheckControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/HealthCheck/ExecuteActionHealthCheckControllerTests.cs @@ -27,7 +27,7 @@ public async Task Setup() protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() @@ -58,7 +58,7 @@ public async Task Setup() protected override async Task ClientRequest() { HealthCheckActionRequestModel healthCheckActionRequest = - new() { HealthCheck = new ReferenceByIdModel(_dataIntegrityHealthCheckId), ValueRequired = false }; + new() { HealthCheck = new ReferenceByIdModel(_dataIntegrityHealthCheckId), ValueRequired = false, Alias = "fixContentPaths" }; return await Client.PostAsync(Url, JsonContent.Create(healthCheckActionRequest)); } } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllLogViewerControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllLogViewerControllerTests.cs index a5e4a2b4ebce..9e8c1844aea2 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllLogViewerControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllLogViewerControllerTests.cs @@ -12,7 +12,7 @@ public class AllLogViewerControllerTests : ManagementApiUserGroupTestBase new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllMessageTemplateLogViewerControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllMessageTemplateLogViewerControllerTests.cs index 71c5d12ebfe6..71df00279432 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllMessageTemplateLogViewerControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/AllMessageTemplateLogViewerControllerTests.cs @@ -11,7 +11,7 @@ public class AllMessageTemplateLogViewerControllerTests : ManagementApiUserGroup // We get the InternalServerError for the admin because it has access, but there is no log file to view protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/LogLevelCountLogViewerControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/LogLevelCountLogViewerControllerTests.cs index 96efcdb4ee98..4fd81272814e 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/LogLevelCountLogViewerControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/LogLevelCountLogViewerControllerTests.cs @@ -11,7 +11,7 @@ public class LogLevelCountLogViewerControllerTests : ManagementApiUserGroupTestB // We get the InternalServerError for the admin because it has access, but there is no log file to view protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/ValidateLogFileSizeLogViewerControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/ValidateLogFileSizeLogViewerControllerTests.cs index fcd26afd5a77..56622b6db678 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/ValidateLogFileSizeLogViewerControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/LogViewer/ValidateLogFileSizeLogViewerControllerTests.cs @@ -11,7 +11,7 @@ public class ValidateLogFileSizeLogViewerControllerTests: ManagementApiUserGroup // We get the InternalServerError for the admin because it has access, but there is no log file to view protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/PropertyType/IsUsedPropertyTypeControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/PropertyType/IsUsedPropertyTypeControllerTests.cs index f9a70160197b..c689ad242e57 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/PropertyType/IsUsedPropertyTypeControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/PropertyType/IsUsedPropertyTypeControllerTests.cs @@ -36,7 +36,7 @@ public async Task Setup() protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError + ExpectedStatusCode = HttpStatusCode.OK }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/User/InviteUserControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/User/InviteUserControllerTests.cs index de91676ec3b7..827e958349f9 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/User/InviteUserControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/User/InviteUserControllerTests.cs @@ -27,7 +27,7 @@ public async Task SetUp() protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() { - ExpectedStatusCode = HttpStatusCode.InternalServerError, + ExpectedStatusCode = HttpStatusCode.InternalServerError, // We expect an error here because email sending is not configured in these tests. }; protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() diff --git a/tests/Umbraco.Tests.Integration/Testing/SqliteTestDatabase.cs b/tests/Umbraco.Tests.Integration/Testing/SqliteTestDatabase.cs index a20b00ebe598..73b9c444a7ae 100644 --- a/tests/Umbraco.Tests.Integration/Testing/SqliteTestDatabase.cs +++ b/tests/Umbraco.Tests.Integration/Testing/SqliteTestDatabase.cs @@ -96,6 +96,8 @@ protected override void RebuildSchema(IDbCommand command, TestDbMeta meta) database.Mappers.Add(new NullableDateMapper()); database.Mappers.Add(new SqlitePocoGuidMapper()); + database.Mappers.Add(new SqlitePocoDecimalMapper()); + database.Mappers.Add(new SqlitePocoDateAndTimeOnlyMapper()); foreach (var dbCommand in _cachedDatabaseInitCommands) { diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs index 42aba90eb2f3..e8101d94140f 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; using NUnit.Framework; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -11,6 +10,8 @@ namespace Umbraco.Cms.Tests.Integration.Testing; public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest { + protected const string TextpageContentTypeKey = "1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"; + protected const string TextpageKey = "B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"; protected const string SubPageKey = "07EABF4A-5C62-4662-9F2A-15BBB488BCA5"; protected const string SubPage2Key = "0EED78FC-A6A8-4587-AB18-D3AFE212B1C4"; @@ -48,7 +49,7 @@ public virtual void CreateTestData() // Create and Save ContentType "umbTextpage" -> 1051 (template), 1052 (content type) ContentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage", defaultTemplateId: template.Id); - ContentType.Key = new Guid("1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"); + ContentType.Key = new Guid(TextpageContentTypeKey); ContentTypeService.Save(ContentType); // Create and Save Content "Homepage" based on "umbTextpage" -> 1053 diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs index 498e6b1f61da..5893333e8ace 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.IO; using System.Text; using NUnit.Framework; using Umbraco.Cms.Core.Hosting; @@ -69,6 +68,55 @@ public void Can_Delete_MediaFiles() Assert.IsTrue(Directory.Exists(physPath)); } + [Test] + public void Can_Add_Suffix_To_Media_Files() + { + var mediaFileManager = GetRequiredService(); + var hostingEnvironment = GetRequiredService(); + + CreateMediaFile(mediaFileManager, hostingEnvironment, out string virtualPath, out string physicalPath); + Assert.IsTrue(File.Exists(physicalPath)); + + mediaFileManager.SuffixMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix); + Assert.IsFalse(File.Exists(physicalPath)); + + var virtualPathWithSuffix = virtualPath.Replace("file.txt", $"file{Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix}.txt"); + physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPathWithSuffix)); + Assert.IsTrue(File.Exists(physicalPath)); + } + + [Test] + public void Can_Remove_Suffix_From_Media_Files() + { + var mediaFileManager = GetRequiredService(); + var hostingEnvironment = GetRequiredService(); + + CreateMediaFile(mediaFileManager, hostingEnvironment, out string virtualPath, out string physicalPath); + mediaFileManager.SuffixMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix); + Assert.IsFalse(File.Exists(physicalPath)); + + mediaFileManager.RemoveSuffixFromMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix); + Assert.IsFalse(File.Exists(physicalPath)); + + var virtualPathWithSuffix = virtualPath.Replace("file.txt", $"file{Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix}.txt"); + physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPathWithSuffix)); + Assert.IsTrue(File.Exists(physicalPath)); + } + + private static void CreateMediaFile( + MediaFileManager mediaFileManager, + IHostingEnvironment hostingEnvironment, + out string virtualPath, + out string physicalPath) + { + virtualPath = mediaFileManager.GetMediaPath("file.txt", Guid.NewGuid(), Guid.NewGuid()); + physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPath)); + + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes("test")); + mediaFileManager.FileSystem.AddFile(virtualPath, memoryStream); + Assert.IsTrue(File.Exists(physicalPath)); + } + // TODO: don't make sense anymore /* [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs index e994e888662d..c3932fabb390 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs @@ -38,8 +38,7 @@ private void ClearFiles(IHostingEnvironment hostingEnvironment) { TestHelper.DeleteDirectory(hostingEnvironment.MapPathContentRoot("FileSysTests")); TestHelper.DeleteDirectory( - hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData.EnsureEndsWith('/') + - "ShadowFs")); + hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData.EnsureEndsWith('/') + "ShadowFs")); } private static string NormPath(string path) => path.Replace('\\', Path.AltDirectorySeparatorChar); @@ -166,6 +165,49 @@ public void ShadowDeleteFile() Assert.IsTrue(files.Contains("f2.txt")); } + [Test] + public void ShadowMoveFile() + { + var path = HostingEnvironment.MapPathContentRoot("FileSysTests"); + Directory.CreateDirectory(path); + Directory.CreateDirectory(path + "/ShadowTests"); + Directory.CreateDirectory(path + "/ShadowSystem"); + + var fs = new PhysicalFileSystem(IOHelper, HostingEnvironment, Logger, path + "/ShadowTests/", "ignore"); + var sfs = new PhysicalFileSystem(IOHelper, HostingEnvironment, Logger, path + "/ShadowSystem/", "ignore"); + var ss = new ShadowFileSystem(fs, sfs); + + File.WriteAllText(path + "/ShadowTests/f1.txt", "foo"); + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes("foo"))) + { + ss.AddFile("f1.txt", ms); + } + + var files = fs.GetFiles(string.Empty); + Assert.AreEqual(1, files.Count()); + Assert.IsTrue(files.Contains("f1.txt")); + + files = ss.GetFiles(string.Empty); + Assert.AreEqual(1, files.Count()); + Assert.IsTrue(files.Contains("f1.txt")); + + var dirs = ss.GetDirectories(string.Empty); + Assert.AreEqual(0, dirs.Count()); + + ss.MoveFile("f1.txt", "f2.txt"); + + Assert.IsTrue(File.Exists(path + "/ShadowTests/f1.txt")); + Assert.IsFalse(File.Exists(path + "/ShadowTests/f2.txt")); + Assert.IsTrue(fs.FileExists("f1.txt")); + Assert.IsFalse(fs.FileExists("f2.txt")); + Assert.IsFalse(ss.FileExists("f1.txt")); + Assert.IsTrue(ss.FileExists("f2.txt")); + + files = ss.GetFiles(string.Empty); + Assert.AreEqual(1, files.Count()); + Assert.IsTrue(files.Contains("f2.txt")); + } + [Test] public void ShadowDeleteFileInDir() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs index 9c8b266bc586..a695a1bb1079 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs @@ -1,11 +1,13 @@ using NUnit.Framework; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; @@ -23,6 +25,8 @@ internal sealed class DocumentUrlServiceTests : UmbracoIntegrationTestWithConten protected ILanguageService LanguageService => GetRequiredService(); + protected IDomainService DomainService => GetRequiredService(); + protected override void CustomTestSetup(IUmbracoBuilder builder) { builder.Services.AddUnique(); @@ -148,6 +152,64 @@ public async Task GetUrlSegment_For_Published_Then_Deleted_Document_Does_Not_Hav Assert.IsNull(actual); } + [TestCase("/", ExpectedResult = TextpageKey)] + [TestCase("/text-page-1", ExpectedResult = SubPageKey)] + public string? GetDocumentKeyByUri_Without_Domains_Returns_Expected_DocumentKey(string path) + { + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var uri = new Uri("http://example.com" + path); + return DocumentUrlService.GetDocumentKeyByUri(uri, false)?.ToString()?.ToUpper(); + } + + private const string VariantRootPageKey = "1D3283C7-64FD-4F4D-A741-442BDA487B71"; + private const string VariantChildPageKey = "1D3283C7-64FD-4F4D-A741-442BDA487B72"; + + [TestCase("/", "/en", "http://example.com/en/", ExpectedResult = VariantRootPageKey)] + [TestCase("/child-page", "/en", "http://example.com/en/", ExpectedResult = VariantChildPageKey)] + [TestCase("/", "example.com", "http://example.com/", ExpectedResult = VariantRootPageKey)] + [TestCase("/child-page", "example.com", "http://example.com/", ExpectedResult = VariantChildPageKey)] + public async Task GetDocumentKeyByUri_With_Domains_Returns_Expected_DocumentKey(string path, string domain, string rootUrl) + { + var template = TemplateBuilder.CreateTextPageTemplate("variantPageTemplate", "Variant Page Template"); + FileService.SaveTemplate(template); + + var contentType = new ContentTypeBuilder() + .WithAlias("variantPage") + .WithName("Variant Page") + .WithContentVariation(ContentVariation.Culture) + .WithAllowAsRoot(true) + .WithDefaultTemplateId(template.Id) + .Build(); + ContentTypeService.Save(contentType); + + var rootPage = new ContentBuilder() + .WithKey(Guid.Parse(VariantRootPageKey)) + .WithContentType(contentType) + .WithCultureName("en-US", $"Root Page") + .Build(); + var childPage = new ContentBuilder() + .WithKey(Guid.Parse(VariantChildPageKey)) + .WithContentType(contentType) + .WithCultureName("en-US", $"Child Page") + .WithParent(rootPage) + .Build(); + ContentService.Save(rootPage, -1); + ContentService.Save(childPage, -1); + ContentService.PublishBranch(rootPage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var updateDomainResult = await DomainService.UpdateDomainsAsync( + rootPage.Key, + new DomainsUpdateModel + { + Domains = [new DomainModel { DomainName = domain, IsoCode = "en-US" }], + }); + Assert.IsTrue(updateDomainResult.Success); + + var uri = new Uri(rootUrl + path); + return DocumentUrlService.GetDocumentKeyByUri(uri, false)?.ToString()?.ToUpper(); + } + [TestCase("/", "en-US", true, ExpectedResult = TextpageKey)] [TestCase("/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] [TestCase("/text-page-1-custom", "en-US", true, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document. @@ -160,7 +222,7 @@ public async Task GetUrlSegment_For_Published_Then_Deleted_Document_Does_Not_Hav [TestCase("/text-page-2", "en-US", false, ExpectedResult = null)] [TestCase("/text-page-2-custom", "en-US", false, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document. [TestCase("/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] - public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_DocumentKey(string route, string isoCode, bool loadDraft) { if (loadDraft is false) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexInitializer.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexInitializer.cs index 1d7cf2e8418e..0b1f6029073e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexInitializer.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexInitializer.cs @@ -39,6 +39,7 @@ public class IndexInitializer private readonly IContentTypeService _contentTypeService; private readonly IDocumentUrlService _documentUrlService; private readonly ILanguageService _languageService; + private readonly IOptionsMonitor _indexSettings; public IndexInitializer( IShortStringHelper shortStringHelper, @@ -50,7 +51,8 @@ public IndexInitializer( ILocalizationService localizationService, IContentTypeService contentTypeService, IDocumentUrlService documentUrlService, - ILanguageService languageService) + ILanguageService languageService, + IOptionsMonitor indexSettings) { _shortStringHelper = shortStringHelper; _propertyEditors = propertyEditors; @@ -62,6 +64,7 @@ public IndexInitializer( _contentTypeService = contentTypeService; _documentUrlService = documentUrlService; _languageService = languageService; + _indexSettings = indexSettings; } public ContentValueSetBuilder GetContentValueSetBuilder(bool publishedValuesOnly) @@ -91,7 +94,8 @@ public ContentIndexPopulator GetContentIndexRebuilder(IContentService contentSer null, contentService, umbracoDatabaseFactory, - contentValueSetBuilder); + contentValueSetBuilder, + _indexSettings); return contentIndexDataSource; } @@ -105,7 +109,7 @@ public MediaIndexPopulator GetMediaIndexRebuilder(IMediaService mediaService) _shortStringHelper, _contentSettings, StaticServiceProvider.Instance.GetRequiredService()); - var mediaIndexDataSource = new MediaIndexPopulator(null, mediaService, mediaValueSetBuilder); + var mediaIndexDataSource = new MediaIndexPopulator(null, mediaService, mediaValueSetBuilder, _indexSettings); return mediaIndexDataSource; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs index 95a7807fad33..8c0544421fa7 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs @@ -34,6 +34,24 @@ public async Task Cannot_Delete_When_Content_Is_Related_As_A_Child_And_Configure Assert.IsNotNull(subpage); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] + public async Task Can_Delete_When_Content_Is_Related_To_Parent_For_Restore_And_Configured_To_Disable_When_Related() + { + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + + // Setup a relation where the page being deleted is related to it's parent (created as the location to restore to). + Relate(Subpage2, Subpage, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias); + var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify is deleted + var subpage = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNull(subpage); + } + [Test] [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 4fca28f2ffd1..4c8508cfa684 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -16,9 +16,9 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase [SetUp] public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; - public void Relate(IContent parent, IContent child) + public void Relate(IContent parent, IContent child, string relationTypeAlias = Constants.Conventions.RelationTypes.RelatedDocumentAlias) { - var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); + var relatedContentRelType = RelationService.GetRelationTypeByAlias(relationTypeAlias); var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType); RelationService.Save(relation); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyTypeUsageServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyTypeUsageServiceTests.cs new file mode 100644 index 000000000000..1905e2391292 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyTypeUsageServiceTests.cs @@ -0,0 +1,26 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class PropertyTypeUsageServiceTests : UmbracoIntegrationTestWithContent +{ + private IPropertyTypeUsageService PropertyTypeUsageService => GetRequiredService(); + + [TestCase(TextpageContentTypeKey, "title", true, true, PropertyTypeOperationStatus.Success)] + [TestCase("1D3A8E6E-2EA9-4CC1-B229-1AEE19821523", "title", false, false, PropertyTypeOperationStatus.ContentTypeNotFound)] + [TestCase(TextpageContentTypeKey, "missingProperty", true, false, PropertyTypeOperationStatus.Success)] + public async Task Can_Check_For_Saved_Property_Values(Guid contentTypeKey, string propertyAlias, bool expectedSuccess, bool expectedResult, PropertyTypeOperationStatus expectedOperationStatus) + { + Attempt resultAttempt = await PropertyTypeUsageService.HasSavedPropertyValuesAsync(contentTypeKey, propertyAlias); + Assert.AreEqual(expectedSuccess, resultAttempt.Success); + Assert.AreEqual(expectedResult, resultAttempt.Result); + Assert.AreEqual(expectedOperationStatus, resultAttempt.Status); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs index 396ad944154c..fcd922a43824 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.IO; using System.Text; using Microsoft.Extensions.Logging; using Moq; @@ -82,6 +81,24 @@ public void SaveFileTest() }); } + [Test] + public void MoveFileTest() + { + var basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FileSysTests"); + + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes("foo"))) + { + _fileSystem.AddFile("sub/f3.txt", ms); + } + + Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub/f3.txt"))); + + _fileSystem.MoveFile("sub/f3.txt", "sub2/f4.txt"); + + Assert.IsFalse(File.Exists(Path.Combine(basePath, "sub/f3.txt"))); + Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub2/f4.txt"))); + } + [Test] public void GetFullPathTest() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs index 72de48631f90..01b8a428c39b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs @@ -58,4 +58,24 @@ public void Validates_Color_Vals() PropertyValidationContext.Empty()); Assert.AreEqual(2, result.Count()); } + + [Test] + public void Validates_Color_Vals_Are_Unique() + { + var validator = new ColorPickerConfigurationEditor.ColorListValidator(ConfigurationEditorJsonSerializer()); + var result = + validator.Validate( + new JsonArray( + JsonNode.Parse("""{"value": "FFFFFF", "label": "One"}"""), + JsonNode.Parse("""{"value": "000000", "label": "Two"}"""), + JsonNode.Parse("""{"value": "FF00AA", "label": "Three"}"""), + JsonNode.Parse("""{"value": "fff", "label": "Four"}"""), + JsonNode.Parse("""{"value": "000000", "label": "Five"}"""), + JsonNode.Parse("""{"value": "F0A", "label": "Six"}""")), + null, + null, + PropertyValidationContext.Empty()); + Assert.AreEqual(1, result.Count()); + Assert.IsTrue(result.First().ErrorMessage.Contains("ffffff, 000000, ff00aa")); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs index b70523403e71..4e16f85db135 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs @@ -161,7 +161,27 @@ public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max_With_Configured_Wh Assert.AreEqual(1, result.Count()); var validationResult = result.First(); - Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + Assert.AreEqual("validation_outOfRangeMaximum", validationResult.ErrorMessage); + } + } + + [TestCase(1.8, true)] + [TestCase(2.2, false)] + [SetCulture("it-IT")] // Uses "," as the decimal separator. + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max_With_Comma_Decimal_Separator(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(min: 1, max: 2); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_outOfRangeMaximum", validationResult.ErrorMessage); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs index 6949d47c8883..d73fff433b74 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs @@ -1,5 +1,6 @@ -using Moq; +using Moq; using NUnit.Framework; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; @@ -44,9 +45,10 @@ public void FilterAvailable_Invariant_ForPreview_YieldsUnpublishedItems() [TestCase("da-DK", 3)] [TestCase("en-US", 4)] + [TestCase("*", 5)] public void FilterAvailable_Variant_ForNonPreview_YieldsPublishedItemsInCulture(string culture, int expectedNumberOfChildren) { - var (sut, items) = SetupVariant(false, culture); + var (sut, items) = SetupVariant(false, culture == Constants.System.InvariantCulture ? "en-US" : culture); var children = sut.FilterAvailable(items.Keys, culture).ToArray(); Assert.AreEqual(expectedNumberOfChildren, children.Length); @@ -70,16 +72,24 @@ public void FilterAvailable_Variant_ForNonPreview_YieldsPublishedItemsInCulture( { Assert.AreEqual(8, children[2].Id); } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(4, children[2].Id); + Assert.AreEqual(6, children[3].Id); + Assert.AreEqual(8, children[4].Id); + } } - [TestCase("da-DK")] - [TestCase("en-US")] - public void FilterAvailable_Variant_ForPreview_YieldsUnpublishedItemsInCulture(string culture) + [TestCase("da-DK", 7)] + [TestCase("en-US", 7)] + [TestCase("*", 10)] + public void FilterAvailable_Variant_ForPreview_YieldsUnpublishedItemsInCulture(string culture, int expectedNumberOfChildren) { - var (sut, items) = SetupVariant(true, culture); + var (sut, items) = SetupVariant(true, culture == Constants.System.InvariantCulture ? "en-US" : culture); var children = sut.FilterAvailable(items.Keys, culture).ToArray(); - Assert.AreEqual(7, children.Length); + Assert.AreEqual(expectedNumberOfChildren, children.Length); // IDs 0 through 3 exist in both en-US and da-DK Assert.Multiple(() => @@ -105,16 +115,27 @@ public void FilterAvailable_Variant_ForPreview_YieldsUnpublishedItemsInCulture(s Assert.AreEqual(8, children[5].Id); Assert.AreEqual(9, children[6].Id); } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(4, children[4].Id); + Assert.AreEqual(5, children[5].Id); + Assert.AreEqual(6, children[6].Id); + Assert.AreEqual(7, children[7].Id); + Assert.AreEqual(8, children[8].Id); + Assert.AreEqual(9, children[9].Id); + } } - [TestCase("da-DK")] - [TestCase("en-US")] - public void FilterAvailable_MixedVariance_ForNonPreview_YieldsPublishedItemsInCultureOrInvariant(string culture) + [TestCase("da-DK", 4)] + [TestCase("en-US", 4)] + [TestCase("*", 5)] + public void FilterAvailable_MixedVariance_ForNonPreview_YieldsPublishedItemsInCultureOrInvariant(string culture, int expectedNumberOfChildren) { - var (sut, items) = SetupMixedVariance(false, culture); + var (sut, items) = SetupMixedVariance(false, culture == Constants.System.InvariantCulture ? "en-US" : culture); var children = sut.FilterAvailable(items.Keys, culture).ToArray(); - Assert.AreEqual(4, children.Length); + Assert.AreEqual(expectedNumberOfChildren, children.Length); // IDs 0 through 2 are invariant - only even IDs are published Assert.Multiple(() => @@ -140,16 +161,23 @@ public void FilterAvailable_MixedVariance_ForNonPreview_YieldsPublishedItemsInCu { Assert.AreEqual(8, children[3].Id); } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(6, children[3].Id); + Assert.AreEqual(8, children[4].Id); + } } - [TestCase("da-DK")] - [TestCase("en-US")] - public void FilterAvailable_MixedVariance_FoPreview_YieldsPublishedItemsInCultureOrInvariant(string culture) + [TestCase("da-DK", 8)] + [TestCase("en-US", 8)] + [TestCase("*", 10)] + public void FilterAvailable_MixedVariance_ForPreview_YieldsPublishedItemsInCultureOrInvariant(string culture, int expectedNumberOfChildren) { - var (sut, items) = SetupMixedVariance(true, culture); + var (sut, items) = SetupMixedVariance(true, culture == Constants.System.InvariantCulture ? "en-US" : culture); var children = sut.FilterAvailable(items.Keys, culture).ToArray(); - Assert.AreEqual(8, children.Length); + Assert.AreEqual(expectedNumberOfChildren, children.Length); // IDs 0 through 2 are invariant Assert.Multiple(() => @@ -180,6 +208,14 @@ public void FilterAvailable_MixedVariance_FoPreview_YieldsPublishedItemsInCultur Assert.AreEqual(8, children[6].Id); Assert.AreEqual(9, children[7].Id); } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(6, children[6].Id); + Assert.AreEqual(7, children[7].Id); + Assert.AreEqual(8, children[8].Id); + Assert.AreEqual(9, children[9].Id); + } } // sets up invariant test data: @@ -328,7 +364,7 @@ private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary items .TryGetValue(key, out var item) && idIsPublished(item.Id) - && (item.ContentType.VariesByCulture() is false || item.Cultures.ContainsKey(culture))); + && (culture == Constants.System.InvariantCulture || item.ContentType.VariesByCulture() is false || item.Cultures.ContainsKey(culture))); publishStatusQueryService .Setup(s => s.HasPublishedAncestorPath(It.IsAny())) .Returns(true); diff --git a/version.json b/version.json index 534b8cada087..2ba7176858c0 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "17.0.0-rc3", + "version": "17.1.0-rc", "assemblyVersion": { "precision": "build" },