Compare commits

...
Author SHA1 Message Date
Niels LyngsøandGitHub 0331cd154b Update 01_bug_report.yml (#18513) 2025-03-24 14:59:10 +01:00
Jacob Overgaard 0f98b8342c Update branches (v16)
Removed v12, added v15 and v16
2025-03-20 10:49:02 +01:00
dependabot[bot]andJacob Overgaard b13f90046d Bump esbuild and vite in /src/Umbraco.Web.UI.Client
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.1 and updates ancestor dependency [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Updates `esbuild` from 0.24.2 to 0.25.1
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.24.2...v0.25.1)

Updates `vite` from 6.1.0 to 6.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.2/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: indirect
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-20 10:15:54 +01:00
Jacob Overgaard 304db8c28a Merge branch 'v15/dev' into contrib 2025-03-20 09:42:56 +01:00
dependabot[bot]andJacob Overgaard 723d3be905 Bump esbuild and vite in /src/Umbraco.Web.UI.Login
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.1 and updates ancestor dependency [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Updates `esbuild` from 0.24.2 to 0.25.1
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.24.2...v0.25.1)

Updates `vite` from 6.1.0 to 6.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.2/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: indirect
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-20 09:35:07 +01:00
Andy Butlandandkjac 5f37cd3d20 Make preview check for delivery API content case insensitive. (#18731) 2025-03-20 06:56:43 +01:00
Warren BuckleyandGitHub 203cc0e380 Updates dashboard to use uui-box headline property/attribute & place button in header-actions (#18736) 2025-03-19 23:29:56 +00:00
442de9a803 remove open config (#18592)
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2025-03-19 20:24:25 +00:00
Mads RasmussenandGitHub eea6b9b2f1 Feature: Language Item Ref (#18521)
* add entity type to interface

* use item-ref in language input

* register ref item
2025-03-19 20:51:25 +01:00
b3b1ce2dcd Bugfix: block culture permissions (#18665)
* always allow copy

* add back readonly check

* render pencil icon if the property is read only

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2025-03-19 19:42:04 +00:00
69b69a5fc1 V15: Change drag and drop icon (#18519)
* register new drag icons

* use drag icon in sort-children-of modal

* update drag icons

* only use the vertical icon style

* Update property-editor-ui-image-crops.element.ts

* rename to grib

* reset formatting for merge conflict

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2025-03-19 19:39:18 +00:00
Mads RasmussenandGitHub 605cda9224 Bugfix: Document granular user permission preset (#18651)
* pass fallback permissions to document granular permissions

* set as preset

* clean up
2025-03-19 19:36:12 +00:00
b65ad4e234 Fix focal point drag not working on firefox (#18674)
* fix focal point drag not working on firefox

* prevent reacting to left click

* only left click for drag

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2025-03-19 16:15:34 +00:00
Jacob OvergaardandGitHub 4e680c88bb build(deps): bump @umbraco-ui/uui from 1.13.0-rc.1 to 1.13.0-rc.2 (#18677) 2025-03-19 15:20:09 +00:00
Jacob OvergaardandGitHub e8f991f485 V15: Adds accepted image types for image cropper (#18715)
* fix: adds accepted image types for image cropper

* chore: cleanup imports and types

* feat: adds a loader until all prerequisites have been resolved for image cropper

* feat: adds method to share temp file configuration
2025-03-19 12:36:51 +00:00
leekelleherandJacob Overgaard 66891333a8 Refactored the Tiptap Table toolbar button
to only show the table actions when the table is active,
otherwise show the Insert Table feature.
2025-03-19 13:26:04 +01:00
Lee KelleherandGitHub 044c9d2c87 Tiptap RTE: Table column/row bubble menu (#18708)
* Code formatting

* Icons for Tiptap table row/column actions

* Combined Tiptap exports

Removed extra constant

* Added Table Column Menu

* Added Table Row Menu

Made the bubble menu reusable
2025-03-19 13:25:13 +01:00
Andy ButlandandGitHub 7b0c419ace Adds detail of how many characters you are over when validating text fields for length. (#18722) 2025-03-19 12:02:40 +01:00
Jacob Overgaard 6ec1b9f1e4 build(deps-dev): bump storybook from 8.5.7 to 8.6.7 and run necessary migrations 2025-03-19 10:14:59 +01:00
Andy ButlandandGitHub 18069633b2 Used documented settings for the media seed cache. (#18723) 2025-03-19 09:47:12 +01:00
b24c29c647 V15: Extend search endpoints (#18634)
* Extend content type search endpoint

* Refactor to be able to specify trashed or not

* Simplify and extract into own method

* Fix breaking ctor

* Make non actions in controllers

* Fox up search service

* Add third ctor to avoid errors

* Update query to filter by is element

* Also implement for media

* Minor formatting and clean-up

* Re-introduce (and obsolete) the previous constructor

---------

Co-authored-by: kjac <kja@umbraco.dk>
2025-03-19 09:12:44 +01:00
Andreas ZerbstandGitHub f3f7fcc051 V15 QA added acceptance tests for block list variants (#18654)
* Moved tests

* Added variant tests

* Updated usage of helper

* Added tests

* Cleaned up

* Bumped version and added test command
2025-03-18 11:17:07 +00:00
Andreas ZerbstandGitHub b6fa93edac V15 QA added Tip Tap block variant acceptance tests (#18668)
* Moved tests

* Added variant tests for tiptap

* Updated file name

* Added test command

* Bumped package lock file

* removed npm command

* Bumped test helpers
2025-03-18 10:37:11 +00:00
Andy ButlandandGitHub b07a24ba55 Added clarifying comments to the logic for granular permissions. (#18705) 2025-03-18 11:08:30 +01:00
Andy ButlandandGitHub 42cb9b5c1e Removes one of the two duplicate ContentPublishingNotification publishings. (#18702) 2025-03-18 10:52:32 +01:00
Andreas ZerbstandGitHub 88657c1bb0 V15 QA added acceptance tests for block grid variants (#18658)
* Added block grid variant tests

* Bumped version

* Updated command to run tests on pipeline

* Removed test command

* Fixed comment
2025-03-18 09:35:28 +00:00
Andy ButlandandGitHub e91a25dcb6 Restrict valid API user client IDs to 100 characters. (#18688) 2025-03-18 06:33:24 +01:00
Lee KelleherandGitHub 81e63e7fa9 Tiptap RTE: Added data-mark attributes (#18689)
Tiptap: adds `data-mark` attributes to RTE, toolbar and toolbar items.
2025-03-18 11:22:20 +07:00
Andy ButlandandGitHub 2f9d58fc68 Fixes link for access to composition from document type property outside of group. (#18686) 2025-03-17 11:16:35 +01:00
Niels Lyngsø e9ba06929f Merge branch 'release/15.3' into v15/dev
# Conflicts:
#	version.json
2025-03-17 11:09:18 +01:00
Andy Butland a5da305093 Merge remote-tracking branch 'origin/v13/dev' into v15/dev
# Conflicts:
#	Directory.Packages.props
#	build/azure-pipelines.yml
#	src/Umbraco.Core/EmbeddedResources/Lang/da.xml
#	src/Umbraco.Core/EmbeddedResources/Lang/en.xml
#	src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml
#	src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs
#	src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs
#	src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandler.cs
#	src/Umbraco.Web.BackOffice/Authorization/PermissionsQueryStringHandler.cs
#	src/Umbraco.Web.BackOffice/Controllers/ContentController.cs
#	src/Umbraco.Web.BackOffice/Controllers/MediaController.cs
#	src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs
#	src/Umbraco.Web.UI.Client/lib/tinymce/langs/da.js
#	src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js
#	src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js
#	src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-cards.less
#	src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js
#	src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html
#	src/Umbraco.Web.UI.Client/src/views/users/views/users/users.html
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandlerTests.cs
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandlerTests.cs
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceHandlerTests.cs
2025-03-17 09:20:40 +01:00
Andy ButlandandGitHub e1e5f527e4 Updates dependency on Examine to 3.7. (#18676) 2025-03-17 09:13:09 +01:00
Niels Lyngsø f4e0e38824 Merge branch 'contrib' into v15/dev 2025-03-14 10:21:27 +01:00
Niels Lyngsø fa09ce6214 justify tag 2025-03-14 10:20:23 +01:00
Lee KelleherandGitHub dd8fb66300 Content Picker: Filter out invalid entity types (#18660)
* Reverted `elementName` constant

+ code tidy-up

* Filter out values that don't match the configured entity-type

Fixes #18476

* Notifies the user of invalid data/configuration

with an option to remove invalid data.
2025-03-14 09:55:45 +01:00
Lee KelleherandGitHub a02db287ce Tiptap RTE: Text Indent extension + toolbar items (#18672)
* Tiptap: Text Indent extension

* Updates indent manifest icons
2025-03-13 14:52:58 +00:00
e9c97f8c9b Allow multiple URL segments per document (#18603)
* Code tidy - XML header comments, method ordering, warning resolution.

* Add extension method for retrieving all URL segments for a document.

* Cache and persist multiple URL segments per document.

* Allowed segment providers to terminate or allow additional segments.
Set up currently failing integration test for expected routes.

* Resolved cache issue to ensure passing new integration tests.

* Fixed failing integration test.

* Test class naming tidy up.

* Added resolution and persistance of a primary segment, to retain previous behaviour when a single segment is retrieved.

* Further integration tests.

* Resolved backward compatibility of interface.

* Supress amends made to integration tests.

* Aligned naming of integration tests.

* Removed unused using, added XML header comment.

* Throw on missing table in migration.

* Code clean-up.

* Fix multiple enumeration

* Used default on migrated column.

* Use 1 over true for default value.

* Remove unused logger

---------

Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
2025-03-13 12:47:46 +00:00
Lan Nguyen ThuyandJacob Overgaard f016dbd223 Fix issue text overflow when user name is too long 2025-03-13 11:07:57 +01:00
Mathias HelsengrenandJacob Overgaard da5669d9fc Remove the hardcoded color for the text in error message 2025-03-13 11:04:30 +01:00
Jacob Overgaard 74aa3d9676 bump version to 15.4.0-rc 2025-03-13 10:46:43 +01:00
Niels LyngsøandGitHub c671668e45 remove min height and max height configuration options (#18648) 2025-03-12 21:33:37 +01:00
Niels LyngsøandGitHub e337821d0e align search icon vertical (#18657) 2025-03-12 21:23:41 +01:00
leekelleherandJacob Overgaard 95fcf645dc Awaits super.openPicker() 2025-03-12 13:35:35 +01:00
Andy Butland b2aa04ae1a Merge branch 'release/13.7.2' into v13/dev
# Conflicts:
#	version.json
2025-03-12 12:18:45 +01:00
Andy ButlandandGitHub e270adc50f Reverts UI updates from publish with desdendants dialog. (#18647) 2025-03-12 08:10:34 +01:00
Andy Butlandandkjac de282d0fbf Reverts UI updates from publish with descendants dialog (15) (#18646)
* Reverts UI updates from publish with descendants dialog.

* Removed unused variable.
2025-03-12 08:01:00 +01:00
Andy Butland fd5b8de533 Bumped version to 13.7.2 2025-03-12 07:25:35 +01:00
Andy Butland e3d9b042c2 Avoids collection was modified issue when flowing identities to the authenticated user's principal. (#18527) 2025-03-12 07:24:50 +01:00
Niels LyngsøandGitHub 3586f137fc avoid indifferences on property-entry property order (#18530) 2025-03-11 12:30:14 +01:00
PhyxionNLandGitHub ced3db8542 Add NuGet badge (#18583) 2025-03-11 10:09:35 +01:00
Andy Butland 1f2afbaa1d Bumped version to 15.3.0-rc2. 2025-03-11 09:45:50 +01:00
Andy Butland e8e1b6ef33 Merge commit from fork
* Tighten management API endpoint access rules.

* Bumped version to 15.2.1.
2025-03-11 09:42:58 +01:00
Andy Butland 4803b4303d bumped imagesharp to prevent CVE-2025-27598 (#18602)
# Conflicts:
#	Directory.Packages.props
2025-03-11 09:42:47 +01:00
Andy Butland f4ae796b30 Merge branch 'v13/dev' of https://github.com/umbraco/Umbraco-CMS into v13/dev 2025-03-11 09:18:25 +01:00
Andy Butland 367d0386b6 Merge branch 'release/13.7.1' into v13/dev
# Conflicts:
#	version.json
2025-03-11 09:18:12 +01:00
5b54bed406 Merge commit from fork
* Fixed parsing of node if in content and media permission querystring handlers to retrieve expected value when multiple are provided in the querystring.

* Add HttpPost attributes to backoffice endpoints that should only accept post requests.

* Bumped version to 13.6.1.

* Narrow PermissionQueryString parsing to the releveant UmbracoObjectType

* Add missed update from v10

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
2025-03-11 05:11:08 +01:00
Anders BjernerandMichael Latouche d3b60277d7 Fixed typo in TinyMCE's da.js 2025-03-10 15:38:50 +01:00
twoday-rolandkockandAndy Butland 2ec6ff4ebc bumped imagesharp to prevent CVE-2025-27598 (#18602) 2025-03-09 08:43:52 +01:00
Markus JohanssonandGitHub 900938edd0 V15: Added uui-box around ungrouped properties during block editing (#18571) (#18572) 2025-03-05 08:44:49 +01:00
Andy Butland b9250c20e9 Merge branch 'v15/dev' into contrib 2025-03-04 07:43:10 +01:00
Andy Butland 451b5f96e7 Use windows for build agent to avoid hanging issues with Linux. 2025-02-28 06:57:35 +01:00
Andy Butland 15ebae5025 Bumped version to 13.7.1. 2025-02-28 06:57:00 +01:00
Chris HoustonandGitHub ac3b17b390 Fixes the Fatal icon text so it's readable in Dark Mode. (#18375) 2025-02-18 22:00:19 +01:00
142 changed files with 3697 additions and 2173 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ body:
- type: input
id: "version"
attributes:
label: "Which Umbraco version are you using? (Please write the *exact* version, example: 10.1.0)"
description: "Use the help icon in the Umbraco backoffice to find the version you're using"
label: "Which Umbraco version are you using?"
description: "Please write the *exact* version, example: `10.1.0`. Use the help icon in the Umbraco backoffice to find the version you're using"
validations:
required: true
- type: textarea
+5 -4
View File
@@ -1,11 +1,12 @@
# [Umbraco CMS](https://umbraco.com)
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE.md)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
![Mastodon Follow](https://img.shields.io/mastodon/follow/110661369750014952?domain=https%3A%2F%2Fumbracocommunity.social)
[![Chat about Umbraco on Discord](https://img.shields.io/discord/869656431308189746?logo=discord&logoColor=fff)](https://discord.gg/umbraco)
[![Read what's going on in the Umbraco Discord chat now](https://img.shields.io/badge/read-discord-blue)](https://discord-chats.umbraco.com)
[![NuGet Version](https://img.shields.io/nuget/v/Umbraco.Cms)](https://www.nuget.org/packages/Umbraco.Cms)
[![Build status](https://img.shields.io/azure-devops/build/umbraco/Umbraco%2520Cms/301?logo=azurepipelines&label=Azure%20Pipelines)](https://umbraco.visualstudio.com/Umbraco%20Cms/_build?definitionId=301)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![Chat about Umbraco on Discord](https://img.shields.io/discord/869656431308189746?logo=discord&logoColor=fff)](https://discord.gg/umbraco)
[![Read what's going on in the Umbraco Discord chat now](https://img.shields.io/badge/read-discord-blue)](https://discord-chats.umbraco.com)
![Mastodon Follow](https://img.shields.io/mastodon/follow/110661369750014952?domain=https%3A%2F%2Fumbracocommunity.social)
### Umbraco is a free and open source .NET content management system. Our mission is to help you deliver delightful digital experiences by making Umbraco friendly, simpler and social.
+2 -2
View File
@@ -45,8 +45,8 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.6.0" />
<PackageVersion Include="Examine.Core" Version="3.6.0" />
<PackageVersion Include="Examine" Version="3.7.0" />
<PackageVersion Include="Examine.Core" Version="3.7.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.74" />
<PackageVersion Include="JsonPatch.Net" Version="3.1.1" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
+2 -1
View File
@@ -9,9 +9,10 @@ schedules:
branches:
include:
- v10/dev
- v12/dev
- v13/dev
- v14/dev
- v15/dev
- v16/dev
steps:
- checkout: none
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Services;
@@ -11,5 +11,5 @@ internal sealed class RequestPreviewService : RequestHeaderHandler, IRequestPrev
}
/// <inheritdoc />
public bool IsPreview() => GetHeaderValue("Preview") == "true";
public bool IsPreview() => string.Equals(GetHeaderValue("Preview"), "true", StringComparison.OrdinalIgnoreCase);
}
@@ -30,14 +30,33 @@ public class SearchDocumentItemController : DocumentItemControllerBase
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> SearchFromParent(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, skip, take, parentId);
=> await SearchWithTrashed(cancellationToken, query, null, skip, take, parentId);
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
[ProducesResponseType(typeof(PagedModel<DocumentItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(
CancellationToken cancellationToken,
string query,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null) =>
await SearchWithTrashed(cancellationToken, query, null, skip, take, parentId, allowedDocumentTypes);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<DocumentItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedDocumentTypes = null)
public async Task<IActionResult> SearchWithTrashed(
CancellationToken cancellationToken,
string query,
bool? trashed = null,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null)
{
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, skip, take);
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, trashed, skip, take);
var result = new PagedModel<DocumentItemResponseModel>
{
Items = searchResult.Items.OfType<IDocumentEntitySlim>().Select(_documentPresentationFactory.CreateItemResponseModel),
@@ -1,45 +1,60 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType.Item;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType.Item;
[ApiVersion("1.0")]
public class SearchDocumentTypeItemController : DocumentTypeItemControllerBase
{
private readonly IEntitySearchService _entitySearchService;
private readonly IContentTypeService _contentTypeService;
private readonly IUmbracoMapper _mapper;
private readonly IContentTypeSearchService _contentTypeSearchService;
[Obsolete("Please use ctor that only accepts IUmbracoMapper & IContentTypeSearchService, scheduled for removal in v17")]
public SearchDocumentTypeItemController(IEntitySearchService entitySearchService, IContentTypeService contentTypeService, IUmbracoMapper mapper)
: this(mapper, StaticServiceProvider.Instance.GetRequiredService<IContentTypeSearchService>())
{
_entitySearchService = entitySearchService;
_contentTypeService = contentTypeService;
_mapper = mapper;
}
[Obsolete("Please use ctor that only accepts IUmbracoMapper & IContentTypeSearchService, scheduled for removal in v17")]
// We need to have this constructor, or else we get ambiguous constructor error
public SearchDocumentTypeItemController(
IEntitySearchService entitySearchService,
IContentTypeService contentTypeService,
IUmbracoMapper mapper,
IContentTypeSearchService contentTypeSearchService)
: this(mapper, contentTypeSearchService)
{
}
[ActivatorUtilitiesConstructor]
public SearchDocumentTypeItemController(IUmbracoMapper mapper, IContentTypeSearchService contentTypeSearchService)
{
_mapper = mapper;
_contentTypeSearchService = contentTypeSearchService;
}
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> Search(CancellationToken cancellationToken, string query, int skip = 0, int take = 100)
=> await SearchDocumentType(cancellationToken, query, null, skip, take);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<DocumentTypeItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Search(CancellationToken cancellationToken, string query, int skip = 0, int take = 100)
public async Task<IActionResult> SearchDocumentType(CancellationToken cancellationToken, string query, bool? isElement = null, int skip = 0, int take = 100)
{
PagedModel<IEntitySlim> searchResult = _entitySearchService.Search(UmbracoObjectTypes.DocumentType, query, skip, take);
if (searchResult.Items.Any() is false)
{
return await Task.FromResult(Ok(new PagedModel<DocumentTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IContentType> contentTypes = _contentTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
PagedModel<IContentType> contentTypes = await _contentTypeSearchService.SearchAsync(query, isElement, cancellationToken, skip, take);
var result = new PagedModel<DocumentTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IContentType, DocumentTypeItemResponseModel>(contentTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IContentType, DocumentTypeItemResponseModel>(contentTypes.Items),
Total = contentTypes.Total
};
return Ok(result);
@@ -32,12 +32,17 @@ public class SearchMediaItemController : MediaItemControllerBase
public async Task<IActionResult> SearchFromParent(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, skip, take, parentId);
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, null, skip, take, parentId);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MediaItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, bool? trashed = null, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
{
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, skip, take);
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, trashed, skip, take);
var result = new PagedModel<MediaItemResponseModel>
{
Items = searchResult.Items.OfType<IMediaEntitySlim>().Select(_mediaPresentationFactory.CreateItemResponseModel),
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security.OperationStatus;
@@ -20,7 +20,7 @@ public abstract class ClientCredentialsUserControllerBase : UserControllerBase
.Build()),
BackOfficeUserClientCredentialsOperationStatus.InvalidClientId => BadRequest(problemDetailsBuilder
.WithTitle("Invalid client ID")
.WithDetail("The specified client ID is invalid. A valid client ID can only contain [a-z], [A-Z], [0-9], and [-._~]. Furthermore, including the prefix it cannot be longer than 255 characters.")
.WithDetail("The specified client ID is invalid. A valid client ID can only contain [a-z], [A-Z], [0-9], and [-._~]. Furthermore, including the prefix it cannot be longer than 100 characters.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown client credentials operation status.")
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<language alias="da" intName="Danish" localName="dansk" lcid="6" culture="da-DK">
<creator>
<name>The Umbraco community</name>
@@ -125,7 +125,7 @@ Mange hilsner fra Umbraco robotten
<key alias="outOfRangeMultipleItemsMaximum">De %0% enheder givet er større end det tilladte minimum af %1%.</key>
<key alias="invalidStep">Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%.</key>
<key alias="unexpectedRange">Værdien %0% forventes ikke at indeholde et spænd.</key>
<key alias="stringLengthExceeded">Tekststrengen er længere end den tilladte længde på %0% tegn.</key>
<key alias="stringLengthExceeded">Tekststrengen er længere end den tilladte længde på %0% tegn, %1% for mange.</key>
<key alias="invalidRange">Værdien %0% forventes at have en værdi der er større end fra værdien.</key>
<key alias="invalidObjectType">Det valgte indhold er af den forkerte type.</key>
<key alias="notOneOfOptions">"Værdien '%0%' er ikke en af de tilgængelige valgmuligheder.</key>
@@ -378,7 +378,7 @@
<key alias="invalidPattern">Value is invalid, it does not match the correct pattern</key>
<key alias="entriesShort"><![CDATA[Minimum %0% entries, requires <strong>%1%</strong> more.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters.</key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters, %1% too many.</key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>
<key alias="invalidMemberGroupName">Invalid member group name</key>
<key alias="invalidUserGroupName">Invalid user group name</key>
@@ -395,7 +395,7 @@
<key alias="invalidRange">The value %0% is not expected to have a to value less than the from value</key>
<key alias="entriesShort"><![CDATA[Minimum %0% entries, requires <strong>%1%</strong> more.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters.</key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters, %1% too many.</key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
@@ -11,7 +11,7 @@ public static class ContentBaseExtensions
private static DefaultUrlSegmentProvider? _defaultUrlSegmentProvider;
/// <summary>
/// Gets the URL segment for a specified content and culture.
/// Gets a single URL segment for a specified content and culture.
/// </summary>
/// <param name="content">The content.</param>
/// <param name="shortStringHelper"></param>
@@ -19,29 +19,73 @@ public static class ContentBaseExtensions
/// <param name="culture">The culture.</param>
/// <param name="published">Whether to get the published or draft.</param>
/// <returns>The URL segment.</returns>
/// <remarks>
/// If more than one URL segment provider is available, the first one that returns a non-null value will be returned.
/// </remarks>
public static string? GetUrlSegment(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable<IUrlSegmentProvider> urlSegmentProviders, string? culture = null, bool published = true)
{
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
var urlSegment = GetUrlSegments(content, urlSegmentProviders, culture, published).FirstOrDefault();
if (urlSegmentProviders == null)
{
throw new ArgumentNullException(nameof(urlSegmentProviders));
}
// Ensure we have at least the segment from the default URL provider returned.
urlSegment ??= GetDefaultUrlSegment(shortStringHelper, content, culture, published);
var url = urlSegmentProviders.Select(p => p.GetUrlSegment(content, published, culture)).FirstOrDefault(u => u != null);
if (url == null)
return urlSegment;
}
/// <summary>
/// Gets all URL segments for a specified content and culture.
/// </summary>
/// <param name="content">The content.</param>
/// <param name="shortStringHelper"></param>
/// <param name="urlSegmentProviders"></param>
/// <param name="culture">The culture.</param>
/// <param name="published">Whether to get the published or draft.</param>
/// <returns>The collection of URL segments.</returns>
public static IEnumerable<string> GetUrlSegments(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable<IUrlSegmentProvider> urlSegmentProviders, string? culture = null, bool published = true)
{
var urlSegments = GetUrlSegments(content, urlSegmentProviders, culture, published).Distinct().ToList();
// Ensure we have at least the segment from the default URL provider returned.
if (urlSegments.Count == 0)
{
if (_defaultUrlSegmentProvider == null)
var defaultUrlSegment = GetDefaultUrlSegment(shortStringHelper, content, culture, published);
if (defaultUrlSegment is not null)
{
_defaultUrlSegmentProvider = new DefaultUrlSegmentProvider(shortStringHelper);
urlSegments.Add(defaultUrlSegment);
}
url = _defaultUrlSegmentProvider.GetUrlSegment(content, culture); // be safe
}
return url;
return urlSegments;
}
private static IEnumerable<string> GetUrlSegments(
IContentBase content,
IEnumerable<IUrlSegmentProvider> urlSegmentProviders,
string? culture,
bool published)
{
foreach (IUrlSegmentProvider urlSegmentProvider in urlSegmentProviders)
{
var segment = urlSegmentProvider.GetUrlSegment(content, published, culture);
if (string.IsNullOrEmpty(segment) == false)
{
yield return segment;
if (urlSegmentProvider.AllowAdditionalSegments is false)
{
yield break;
}
}
}
}
private static string? GetDefaultUrlSegment(
IShortStringHelper shortStringHelper,
IContentBase content,
string? culture,
bool published)
{
_defaultUrlSegmentProvider ??= new DefaultUrlSegmentProvider(shortStringHelper);
return _defaultUrlSegmentProvider.GetUrlSegment(content, published, culture);
}
}
@@ -1,9 +1,32 @@
namespace Umbraco.Cms.Core.Models;
/// <summary>
/// Represents a URL segment for a published document.
/// </summary>
public class PublishedDocumentUrlSegment
{
/// <summary>
/// Gets or sets the document key.
/// </summary>
public required Guid DocumentKey { get; set; }
/// <summary>
/// Gets or sets the language Id.
/// </summary>
public required int LanguageId { get; set; }
/// <summary>
/// Gets or sets the URL segment string.
/// </summary>
public required string UrlSegment { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the URL segment is for a draft.
/// </summary>
public required bool IsDraft { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the URL segment is the primary one (first resolved from the collection of URL providers).
/// </summary>
public required bool IsPrimary { get; set; }
}
@@ -110,7 +110,7 @@ public class TextOnlyValueEditor : DataValueEditor
_localizedTextService.Localize(
"validation",
"stringLengthExceeded",
[maxCharacters.ToString()]),
[maxCharacters.ToString(), (typedValue.Length - maxCharacters).ToString() ]),
["value'"])
];
}
+2 -7
View File
@@ -1267,7 +1267,7 @@ public class ContentService : RepositoryService, IContentService
// we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications
if (HasUnsavedChanges(content))
{
return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, EventMessagesFactory.Get(), content);
return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, content);
}
if (content.Name != null && content.Name.Length > 255)
@@ -1324,13 +1324,8 @@ public class ContentService : RepositoryService, IContentService
// Change state to publishing
content.PublishedState = PublishedState.Publishing;
var publishingNotification = new ContentPublishingNotification(content, evtMsgs);
if (scope.Notifications.PublishCancelable(publishingNotification))
{
return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content);
}
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, publishingNotification.State, userId);
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, new Dictionary<string, object?>(), userId);
scope.Complete();
return result;
}
@@ -4,6 +4,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.Changes;
@@ -147,6 +148,16 @@ public class ContentTypeService : ContentTypeServiceBase<IContentTypeRepository,
}
}
public async Task<IEnumerable<IContentType>> GetByQueryAsync(IQuery<IContentType> query, CancellationToken cancellationToken)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
// that one is special because it works across content, media and member types
scope.ReadLock(Constants.Locks.ContentTypes);
IEnumerable<IContentType> contentTypes = Repository.Get(query);
scope.Complete();
return contentTypes;
}
protected override void DeleteItemsOfTypes(IEnumerable<int> typeIds)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IContentTypeSearchService
{
Task<PagedModel<IContentType>> SearchAsync(string query, bool? isElement, CancellationToken cancellationToken, int skip = 0, int take = 100);
}
@@ -1,4 +1,5 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -30,4 +31,6 @@ public interface IContentTypeService : IContentTypeBaseService<IContentType>
/// <param name="aliases"></param>
/// <returns></returns>
IEnumerable<int> GetAllContentTypeIds(string[] aliases);
Task<IEnumerable<IContentType>> GetByQueryAsync(IQuery<IContentType> query, CancellationToken cancellationToken) => Task.FromResult(Enumerable.Empty<IContentType>());
}
@@ -1,38 +1,97 @@
using Umbraco.Cms.Core.Media.EmbedProviders;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Defines operations for handling document URLs.
/// </summary>
public interface IDocumentUrlService
{
/// <summary>
/// Initializes the service and ensure the content in the database is correct with the current configuration.
/// </summary>
/// <param name="forceEmpty"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <param name="forceEmpty">Forces an early return when we know there are no routes (i.e. on install).</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task InitAsync(bool forceEmpty, CancellationToken cancellationToken);
Task RebuildAllUrlsAsync();
/// <summary>
/// Gets the Url from a document key, culture and segment. Preview urls are returned if isPreview is true.
/// Rebuilds all document URLs.
/// </summary>
Task RebuildAllUrlsAsync();
/// <summary>
/// Gets a single URL segment from a document key and culture. Preview urls are returned if isDraft is true.
/// </summary>
/// <param name="documentKey">The key of the document.</param>
/// <param name="culture">The culture code.</param>
/// <param name="isDraft">Whether to get the url of the draft or published document.</param>
/// <returns>The url of the document.</returns>
/// <returns>A URL segment for the document.</returns>
/// <remarks>If more than one segment is available, the first retrieved and indicated as primary will be returned.</remarks>
string? GetUrlSegment(Guid documentKey, string culture, bool isDraft);
/// <summary>
/// Gets the URL segments from a document key and culture. Preview urls are returned if isDraft is true.
/// </summary>
/// <param name="documentKey">The key of the document.</param>
/// <param name="culture">The culture code.</param>
/// <param name="isDraft">Whether to get the url of the draft or published document.</param>
/// <returns>The URL segments for the document.</returns>
IEnumerable<string> GetUrlSegments(Guid documentKey, string culture, bool isDraft)
=> throw new NotImplementedException();
/// <summary>
/// Creates or updates the URL segments for a single document.
/// </summary>
/// <param name="key">The document key.</param>
Task CreateOrUpdateUrlSegmentsAsync(Guid key);
/// <summary>
/// Creates or updates the URL segments for a document and it's descendants.
/// </summary>
/// <param name="key">The document key.</param>
Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key);
/// <summary>
/// Creates or updates the URL segments for a collection of documents.
/// </summary>
/// <param name="documents">The document collection.</param>
Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documents);
/// <summary>
/// Deletes all URLs from the cache for a collection of document keys.
/// </summary>
/// <param name="documentKeys">The collection of document keys.</param>
Task DeleteUrlsFromCacheAsync(IEnumerable<Guid> documentKeys);
/// <summary>
/// Gets a document key by route.
/// </summary>
/// <param name="route">The route.</param>
/// <param name="culture">The culture code.</param>
/// <param name="documentStartNodeId">The document start node Id.</param>
/// <param name="isDraft">Whether to get the url of the draft or published document.</param>
/// <returns>The document key, or null if not found.</returns>
Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft);
/// <summary>
/// Gets all the URLs for a given content key.
/// </summary>
/// <param name="contentKey">The content key.</param>
[Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead. Scheduled for removal in Umbraco 17.")]
Task<IEnumerable<UrlInfo>> ListUrlsAsync(Guid contentKey);
Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key);
Task CreateOrUpdateUrlSegmentsAsync(Guid key);
/// <summary>
/// Gets the legacy route format for a document key and culture.
/// </summary>
/// <param name="key">The key of the document.</param>
/// <param name="culture">The culture code.</param>
/// <param name="isDraft">Whether to get the url of the draft or published document.</param>
/// <returns></returns>
string GetLegacyRouteFormat(Guid key, string? culture, bool isDraft);
/// <summary>
/// Gets a value indicating whether any URLs have been cached.
/// </summary>
bool HasAny();
}
@@ -20,7 +20,19 @@ public interface IIndexedEntitySearchService
PagedModel<IEntitySlim> Search(UmbracoObjectTypes objectType, string query, Guid? parentId, int skip = 0, int take = 100, bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
PagedModel<IEntitySlim> Search(UmbracoObjectTypes objectType, string query, Guid? parentId, IEnumerable<Guid>? contentTypeIds, int skip = 0, int take = 100, bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
string query,
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
}
+1 -1
View File
@@ -2680,7 +2680,7 @@ internal partial class UserService : RepositoryService, IUserService
}
}
[GeneratedRegex(@"^[\w\d\-\._~]{1,255}$")]
[GeneratedRegex(@"^[\w\d\-\._~]{1,100}$")]
private static partial Regex ValidClientId();
#endregion
@@ -8,6 +8,16 @@ namespace Umbraco.Cms.Core.Strings;
/// <remarks>Url segments should comply with IETF RFCs regarding content, encoding, etc.</remarks>
public interface IUrlSegmentProvider
{
/// <summary>
/// Gets a value indicating whether the URL segment provider allows additional segments after providing one.
/// </summary>
/// <remarks>
/// If set to true, when more than one URL segment provider is available, futher providers after this one in the collection will be called
/// even if the current provider provides a segment.
/// If false, the provider will terminate the chain of URL segment providers if it provides a segment.
/// </remarks>
bool AllowAdditionalSegments => false;
/// <summary>
/// Gets the URL segment for a specified content and culture.
/// </summary>
@@ -20,6 +30,18 @@ public interface IUrlSegmentProvider
/// URL per culture.
/// </remarks>
string? GetUrlSegment(IContentBase content, string? culture = null);
/// <summary>
/// Gets the URL segment for a specified content, published status and and culture.
/// </summary>
/// <param name="content">The content.</param>
/// <param name="culture">The culture.</param>
/// <returns>The URL segment.</returns>
/// <remarks>
/// This is for when Umbraco is capable of managing more than one URL
/// per content, in 1-to-1 multilingual configurations. Then there would be one
/// URL per culture.
/// </remarks>
string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => GetUrlSegment(content, culture);
// TODO: For the 301 tracking, we need to add another extended interface to this so that
@@ -1,6 +1,7 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@@ -61,7 +62,19 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
out long totalFound,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, searchFrom, ignoreUserStartNodes);
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, null, searchFrom, ignoreUserStartNodes);
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
public IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
int pageSize,
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, contentTypeAliases, null, searchFrom, ignoreUserStartNodes);
public IEnumerable<ISearchResult> Search(
string query,
@@ -70,6 +83,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
bool? trashed,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
{
@@ -133,6 +147,12 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
? currentUser.CalculateMediaStartNodeIds(_entityService, _appCaches)
: Array.Empty<int>();
AppendPath(sb, UmbracoObjectTypes.Media, allMediaStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
if (trashed.HasValue)
{
AppendRequiredTrashPath(trashed.Value, sb, Constants.System.RecycleBinMedia);
}
break;
case UmbracoEntityTypes.Document:
type = "content";
@@ -146,6 +166,12 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
? currentUser.CalculateContentStartNodeIds(_entityService, _appCaches)
: Array.Empty<int>();
AppendPath(sb, UmbracoObjectTypes.Document, allContentStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
if (trashed.HasValue)
{
AppendRequiredTrashPath(trashed.Value, sb, Constants.System.RecycleBinContent);
}
break;
default:
throw new NotSupportedException("The " + typeof(BackOfficeExamineSearcher) +
@@ -181,6 +207,14 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
return result;
}
private void AppendRequiredTrashPath(bool trashed, StringBuilder sb, int recycleBinId)
{
var requiredOrNotString = trashed ? "+" : "!";
var trashPath = $"-1,{recycleBinId}";
trashPath = trashPath.Replace("-", "\\-").Replace(",", "\\,");
sb.Append($"{requiredOrNotString}__Path:{trashPath}\\,* ");
}
private bool BuildQuery(StringBuilder sb, string query, string? searchFrom, List<string> fields, string type)
{
//build a lucene query:
@@ -394,6 +428,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
searchFromId > 0
? entityService.GetAllPaths(objectType, searchFromId).FirstOrDefault()
: null;
if (entityPath != null)
{
// find... only what's underneath
@@ -78,6 +78,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IContentListViewService, ContentListViewService>();
builder.Services.AddUnique<IMediaListViewService, MediaListViewService>();
builder.Services.AddUnique<IEntitySearchService, EntitySearchService>();
builder.Services.AddUnique<IContentTypeSearchService, ContentTypeSearchService>();
builder.Services.AddUnique<IIndexedEntitySearchService, IndexedEntitySearchService>();
builder.Services.TryAddTransient<IReservedFieldNamesService, ReservedFieldNamesService>();
@@ -18,7 +18,7 @@ public interface IBackOfficeExamineSearcher
string? searchFrom = null,
bool ignoreUserStartNodes = false);
// default implementation to avoid breaking changes falls back to old behaviour
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
@@ -28,5 +28,18 @@ public interface IBackOfficeExamineSearcher
string[]? contentTypeAliases,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, searchFrom, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
int pageSize,
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
bool? trashed,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, searchFrom, ignoreUserStartNodes);
}
@@ -111,5 +111,8 @@ public class UmbracoPlan : MigrationPlan
// To 15.3.0
To<V_15_3_0.AddNameAndDescriptionToWebhooks>("{7B11F01E-EE33-4B0B-81A1-F78F834CA45B}");
// To 15.4.0
To<V_15_4_0.UpdateDocumentUrlToPersistMultipleSegmentsPerDocument>("{A9E72794-4036-4563-B543-1717C73B8879}");
}
}
@@ -28,7 +28,7 @@ public class AddNameAndDescriptionToWebhooks : MigrationBase
}
else
{
Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columnss in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed.");
Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columns in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed.");
}
}
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_4_0;
/// <summary>
/// Migration to make necessary schema updates to support multiple segments per document.
/// </summary>
public class UpdateDocumentUrlToPersistMultipleSegmentsPerDocument : MigrationBase
{
/// <summary>
/// Initializes a new instance of the <see cref="UpdateDocumentUrlToPersistMultipleSegmentsPerDocument"/> class.
/// </summary>
public UpdateDocumentUrlToPersistMultipleSegmentsPerDocument(IMigrationContext context)
: base(context)
{
}
/// <inheritdoc/>
protected override void Migrate()
{
Logger.LogDebug("Schema updates to {TableName} for support of multiple segments per document", Constants.DatabaseSchema.Tables.DocumentUrl);
if (TableExists(Constants.DatabaseSchema.Tables.DocumentUrl))
{
ExtendUniqueIndexAcrossSegmentField();
AddAndPopulateIsPrimaryColumn();
}
else
{
throw new InvalidOperationException(
$"Table {Constants.DatabaseSchema.Tables.DocumentUrl} does not exist so the migration {nameof(UpdateDocumentUrlToPersistMultipleSegmentsPerDocument)} could not be completed.");
}
}
private void ExtendUniqueIndexAcrossSegmentField()
{
Logger.LogDebug("Extending the unique index on {TableName} to include the urlSegment column", Constants.DatabaseSchema.Tables.DocumentUrl);
var indexName = "IX_" + Constants.DatabaseSchema.Tables.DocumentUrl;
if (IndexExists(indexName))
{
DeleteIndex<DocumentUrlDto>(indexName);
}
CreateIndex<DocumentUrlDto>(indexName);
}
private void AddAndPopulateIsPrimaryColumn()
{
const string IsPrimaryColumnName = "isPrimary";
Logger.LogDebug("Adding the {Column} column {TableName} to include the urlSegment column", IsPrimaryColumnName, Constants.DatabaseSchema.Tables.DocumentUrl);
var columns = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database).ToList();
if (columns
.SingleOrDefault(x => x.TableName == Constants.DatabaseSchema.Tables.DocumentUrl && x.ColumnName == IsPrimaryColumnName) is null)
{
AddColumn<DocumentUrlDto>(Constants.DatabaseSchema.Tables.DocumentUrl, IsPrimaryColumnName);
}
}
}
@@ -16,7 +16,7 @@ public class DocumentUrlDto
[PrimaryKeyColumn(Clustered = false, AutoIncrement = true)]
public int NodeId { get; set; }
[Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft", Name = "IX_" + TableName)]
[Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft, urlSegment", Name = "IX_" + TableName)]
[Column("uniqueId")]
[ForeignKey(typeof(NodeDto), Column = "uniqueId")]
public Guid UniqueId { get; set; }
@@ -28,14 +28,11 @@ public class DocumentUrlDto
[ForeignKey(typeof(LanguageDto))]
public int LanguageId { get; set; }
//
// [Column("segment")]
// [NullSetting(NullSetting = NullSettings.Null)]
// [Length(PropertyDataDto.SegmentLength)]
// public string Segment { get; set; } = string.Empty;
[Column("urlSegment")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public string UrlSegment { get; set; } = string.Empty;
[Column("isPrimary")]
[Constraint(Default = 1)]
public bool IsPrimary { get; set; }
}
@@ -1,18 +1,11 @@
using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Factories;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
public class DocumentUrlRepository : IDocumentUrlRepository
@@ -36,14 +29,15 @@ public class DocumentUrlRepository : IDocumentUrlRepository
public void Save(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
{
//TODO avoid this is called as first thing on first restart after install
// TODO: avoid this is called as first thing on first restart after install
IEnumerable<Guid> documentKeys = publishedDocumentUrlSegments.Select(x => x.DocumentKey).Distinct();
Dictionary<(Guid UniqueId, int LanguageId, bool isDraft), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments.Select(BuildDto).ToDictionary(x=> (x.UniqueId, x.LanguageId, x.IsDraft));
Dictionary<(Guid UniqueId, int LanguageId, bool isDraft, string urlSegment), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments
.Select(BuildDto)
.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment));
var toUpdate = new List<DocumentUrlDto>();
var toDelete = new List<int>();
var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft));
var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment));
foreach (IEnumerable<Guid> group in documentKeys.InGroupsOf(Constants.Sql.MaxParameterCount))
{
@@ -57,18 +51,12 @@ public class DocumentUrlRepository : IDocumentUrlRepository
foreach (DocumentUrlDto existing in existingUrlsInBatch)
{
if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft), out DocumentUrlDto? found))
if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft, existing.UrlSegment), out DocumentUrlDto? found))
{
found.NodeId = existing.NodeId;
// Only update if the url segment is different
if (found.UrlSegment != existing.UrlSegment)
{
toUpdate.Add(found);
}
// if we found it, we know we should not insert it as a new
toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft));
// If we found it, we know we should not insert it as a new record.
toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft, found.UrlSegment));
}
else
{
@@ -83,14 +71,6 @@ public class DocumentUrlRepository : IDocumentUrlRepository
Database.DeleteMany<DocumentUrlDto>().Where(x => toDelete.Contains(x.NodeId)).Execute();
}
if (toUpdate.Any())
{
foreach (DocumentUrlDto updated in toUpdate)
{
Database.Update(updated);
}
}
Database.InsertBulk(toInsert.Values);
}
@@ -115,7 +95,8 @@ public class DocumentUrlRepository : IDocumentUrlRepository
UrlSegment = dto.UrlSegment,
DocumentKey = dto.UniqueId,
LanguageId = dto.LanguageId,
IsDraft = dto.IsDraft
IsDraft = dto.IsDraft,
IsPrimary = dto.IsPrimary
};
private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model)
@@ -126,6 +107,7 @@ public class DocumentUrlRepository : IDocumentUrlRepository
UniqueId = model.DocumentKey,
LanguageId = model.LanguageId,
IsDraft = model.IsDraft,
IsPrimary = model.IsPrimary,
};
}
}
@@ -18,6 +18,7 @@ public class UmbracoTreeSearcherFields : IUmbracoTreeSearcherFields
UmbracoExamineFieldNames.CategoryFieldName,
"parentID",
UmbracoExamineFieldNames.ItemTypeFieldName,
UmbracoExamineFieldNames.IndexPathFieldName,
};
private readonly ISet<string> _backOfficeMediaFieldsToLoad =
@@ -0,0 +1,37 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence;
namespace Umbraco.Cms.Infrastructure.Services;
internal sealed class ContentTypeSearchService : IContentTypeSearchService
{
private readonly ISqlContext _sqlContext;
private readonly IContentTypeService _contentTypeService;
public ContentTypeSearchService(ISqlContext sqlContext, IContentTypeService contentTypeService)
{
_sqlContext = sqlContext;
_contentTypeService = contentTypeService;
}
public async Task<PagedModel<IContentType>> SearchAsync(string query, bool? isElement, CancellationToken cancellationToken, int skip = 0, int take = 100)
{
// if the query is a GUID, search for that explicitly
Guid.TryParse(query, out Guid guidQuery);
IQuery<IContentType> nameQuery = isElement is not null ?
_sqlContext.Query<IContentType>().Where(x => (x.Name!.Contains(query) || x.Key == guidQuery) && x.IsElement == isElement) :
_sqlContext.Query<IContentType>().Where(x => x.Name!.Contains(query) || x.Key == guidQuery);
IContentType[] contentTypes = (await _contentTypeService.GetByQueryAsync(nameQuery, cancellationToken)).ToArray();
return new PagedModel<IContentType>
{
Items = contentTypes.Skip(skip).Take(take),
Total = contentTypes.Count()
};
}
}
@@ -52,7 +52,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType, query, parentId, null, skip, take, ignoreUserStartNodes);
=> Search(objectType, query, parentId, null, null, skip, take, ignoreUserStartNodes);
public PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
@@ -62,6 +62,17 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType, query, parentId, contentTypeIds, null, skip, take, ignoreUserStartNodes);
public PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
string query,
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
{
UmbracoEntityTypes entityType = objectType switch
{
@@ -91,6 +102,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
pageNumber,
out var totalFound,
contentTypeAliases,
trashed,
ignoreUserStartNodes: ignoreUserStartNodes,
searchFrom: parentId?.ToString());
@@ -291,8 +291,8 @@ internal class MediaCacheService : IMediaCacheService
private HybridCacheEntryOptions GetSeedEntryOptions() => new()
{
Expiration = _cacheSettings.SeedCacheDuration,
LocalCacheExpiration = _cacheSettings.SeedCacheDuration,
Expiration = _cacheSettings.Entry.Media.SeedCacheDuration,
LocalCacheExpiration = _cacheSettings.Entry.Media.SeedCacheDuration,
};
private string GetCacheKey(Guid key, bool preview) => preview ? $"{key}+draft" : $"{key}";
+3 -1
View File
@@ -35,7 +35,9 @@ const config: StorybookConfig = {
typescript: {
check: true,
},
docs: {},
docs: {
autodocs: true
},
managerHead(head, { configType }) {
const base = process.env.VITE_BASE_PATH || '/';
const injections = [
+740 -1219
View File
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "15.3.0-rc",
"version": "15.4.0-rc",
"type": "module",
"exports": {
".": null,
@@ -216,8 +216,8 @@
"@tiptap/pm": "2.11.5",
"@tiptap/starter-kit": "2.11.5",
"@types/diff": "^7.0.1",
"@umbraco-ui/uui": "^1.13.0-rc.1",
"@umbraco-ui/uui-css": "^1.13.0-rc.0",
"@umbraco-ui/uui": "1.13.0-rc.2",
"@umbraco-ui/uui-css": "1.13.0-rc.2",
"diff": "^7.0.0",
"dompurify": "^3.2.4",
"element-internals-polyfill": "^1.3.13",
@@ -238,12 +238,13 @@
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-replace": "^6.0.2",
"@storybook/addon-a11y": "^8.5.7",
"@storybook/addon-actions": "^8.5.7",
"@storybook/addon-essentials": "^8.5.7",
"@storybook/addon-links": "^8.5.7",
"@storybook/web-components": "^8.5.7",
"@storybook/web-components-vite": "^8.5.7",
"@storybook/addon-a11y": "^8.6.7",
"@storybook/addon-actions": "^8.6.7",
"@storybook/addon-essentials": "^8.6.7",
"@storybook/addon-links": "^8.6.7",
"@storybook/manager-api": "^8.6.7",
"@storybook/web-components": "^8.6.7",
"@storybook/web-components-vite": "^8.6.7",
"@types/chai": "^5.0.1",
"@types/eslint__js": "^8.42.3",
"@types/mocha": "^10.0.10",
@@ -268,14 +269,12 @@
"msw": "^1.3.2",
"playwright-msw": "^3.0.1",
"prettier": "3.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remark-gfm": "^4.0.0",
"rollup": "^4.34.8",
"rollup-plugin-esbuild": "^6.2.0",
"rollup-plugin-import-css": "^3.5.8",
"simple-icons": "^14.7.0",
"storybook": "^8.5.7",
"storybook": "^8.6.7",
"svgo": "^3.3.2",
"tiny-glob": "^0.2.9",
"tsc-alias": "^1.8.10",
@@ -283,7 +282,7 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1",
"typescript-json-schema": "^0.65.1",
"vite": "^6.1.0",
"vite": "^6.2.2",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4",
"web-component-analyzer": "^2.0.0"
@@ -1278,6 +1278,11 @@ export default {
defineRootNode: 'Pick root node',
defineXPathOrigin: 'Specify via XPath',
defineDynamicRoot: 'Specify a Dynamic Root',
unsupportedHeadline: (type?: string) =>
`<strong>Unsupported ${type ?? 'content'} items</strong><br>The following content is no longer supported in this Editor.`,
unsupportedMessage:
'If you still require this content, please contact your administrator. Otherwise you can remove it.',
unsupportedRemove: 'Remove unsupported items?',
},
dynamicRoot: {
configurationTitle: 'Dynamic Root Query',
@@ -0,0 +1,118 @@
/* This Source Code has been derived from Tiptiz.
* https://github.com/tiptiz/editor/blob/main/packages/tiptiz-extension-indent/src/indent.ts
* SPDX-License-Identifier: MIT
* Copyright © 2024 Owen Kriz.
* Modifications are licensed under the MIT License.
*/
import type { Dispatch } from '@tiptap/core';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import { Extension } from '@tiptap/core';
import { AllSelection, TextSelection } from '@tiptap/pm/state';
export interface TextIndentOptions {
minLevel: number;
maxLevel: number;
types: Array<string>;
}
export const TextIndent = Extension.create<TextIndentOptions>({
name: 'textIndent',
addOptions() {
return {
minLevel: 0,
maxLevel: 5,
types: ['heading', 'paragraph', 'listItem', 'taskItem'],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
default: null,
parseHTML: (element) => {
const minLevel = this.options.minLevel;
const maxLevel = this.options.maxLevel;
const indent = element.style.textIndent;
return indent ? Math.max(minLevel, Math.min(maxLevel, parseInt(indent, 10))) : null;
},
renderHTML: (attributes) => {
if (!attributes.indent) return {};
return {
style: `text-indent: ${attributes.indent}rem;`,
};
},
},
},
},
];
},
addCommands() {
const updateNodeIndentMarkup = (tr: Transaction, pos: number, delta: number) => {
const node = tr.doc.nodeAt(pos);
if (!node) return tr;
const minLevel = this.options.minLevel;
const maxLevel = this.options.maxLevel;
let level = (node.attrs.indent || 0) + delta;
level = Math.max(minLevel, Math.min(maxLevel, parseInt(level, 10)));
if (level === node.attrs.indent) return tr;
return tr.setNodeMarkup(pos, node.type, { ...node.attrs, indent: level }, node.marks);
};
const updateIndentLevel = (tr: Transaction, delta: number) => {
if (tr.selection instanceof TextSelection || tr.selection instanceof AllSelection) {
const { from, to } = tr.selection;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (this.options.types.includes(node.type.name)) {
tr = updateNodeIndentMarkup(tr, pos, delta);
return false;
}
return true;
});
}
return tr;
};
type CommanderArgs = {
tr: Transaction;
state: EditorState;
dispatch: Dispatch;
};
const commanderFactory = (direction: number) => () =>
function chainHandler({ tr, state, dispatch }: CommanderArgs) {
const { selection } = state;
tr.setSelection(selection);
tr = updateIndentLevel(tr, direction);
if (tr.docChanged) {
if (dispatch instanceof Function) dispatch(tr);
return true;
}
return false;
};
return {
textIndent: commanderFactory(1),
textOutdent: commanderFactory(-1),
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
textIndent: {
textIndent: () => ReturnType;
textOutdent: () => ReturnType;
};
}
}
@@ -0,0 +1,109 @@
import type { UUIPopoverContainerElement } from '../../uui/index.js';
import { Extension } from '@tiptap/core';
import { Editor } from '@tiptap/core';
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import type { PluginView } from '@tiptap/pm/state';
export interface UmbTiptapBubbleMenuElement extends HTMLElement {
editor?: Editor;
}
export type UmbBubbleMenuPluginProps = {
unique: string;
placement?: UUIPopoverContainerElement['placement'];
elementName?: string | null;
shouldShow?:
| ((props: { editor: Editor; view: EditorView; state: EditorState; from: number; to: number }) => boolean)
| null;
};
export type UmbBubbleMenuOptions = UmbBubbleMenuPluginProps;
export const UmbBubbleMenu = Extension.create<UmbBubbleMenuOptions>({
name: 'umbBubbleMenu',
addOptions() {
return {
unique: 'umb-tiptap-menu',
placement: 'top',
elementName: null,
shouldShow: null,
};
},
addProseMirrorPlugins() {
if (!this.options.unique || !this.options.elementName) {
return [];
}
return [
UmbBubbleMenuPlugin(this.editor, {
unique: this.options.unique,
placement: this.options.placement,
elementName: this.options.elementName,
shouldShow: this.options.shouldShow,
}),
];
},
});
class UmbBubbleMenuPluginView implements PluginView {
#editor: Editor;
#popover: UUIPopoverContainerElement;
#shouldShow: UmbBubbleMenuPluginProps['shouldShow'];
constructor(editor: Editor, view: EditorView, props: UmbBubbleMenuPluginProps) {
this.#editor = editor;
this.#shouldShow = props.shouldShow ?? null;
this.#popover = document.createElement('uui-popover-container') as UUIPopoverContainerElement;
this.#popover.id = props.unique;
this.#popover.setAttribute('placement', props.placement ?? 'top');
this.#popover.setAttribute('popover', 'manual');
if (props.elementName) {
const menu = document.createElement(props.elementName) as UmbTiptapBubbleMenuElement;
menu.editor = editor;
this.#popover.appendChild(menu);
}
view.dom.parentNode?.appendChild(this.#popover);
this.update(view, null);
}
update(view: EditorView, prevState: EditorState | null) {
const editor = this.#editor;
const { state } = view;
const { selection } = state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
const shouldShow = this.#shouldShow?.({ editor, view, state, from, to });
if (!shouldShow) {
this.#popover.hidePopover();
} else {
this.#popover.showPopover();
}
}
destroy() {
this.#popover.remove();
}
}
export const UmbBubbleMenuPlugin = (editor: Editor, props: UmbBubbleMenuPluginProps) => {
return new Plugin({
view(editorView) {
return new UmbBubbleMenuPluginView(editor, editorView, props);
},
});
};
@@ -1,11 +1,9 @@
import { UmbBubbleMenuPlugin } from './tiptap-umb-bubble-menu.extension.js';
import { CellSelection, TableMap } from '@tiptap/pm/tables';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { EditorState } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import { findParentNode, mergeAttributes, Editor, Node } from '@tiptap/core';
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view';
import { EditorState, Plugin, Selection, Transaction } from '@tiptap/pm/state';
import { findParentNode, Editor } from '@tiptap/core';
import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model';
import { Plugin } from '@tiptap/pm/state';
import { Selection, Transaction } from '@tiptap/pm/state';
import { Table } from '@tiptap/extension-table';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
@@ -43,7 +41,16 @@ export const UmbTableHeader = TableHeader.extend({
},
addProseMirrorPlugins() {
const { editor } = this;
return [
UmbBubbleMenuPlugin(this.editor, {
unique: 'table-column-menu',
placement: 'top',
elementName: 'umb-tiptap-table-column-menu',
shouldShow(props) {
return isColumnGripSelected(props);
},
}),
new Plugin({
props: {
decorations: (state) => {
@@ -62,16 +69,16 @@ export const UmbTableHeader = TableHeader.extend({
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
const className = colSelected ? 'grip-column selected' : 'grip-column';
const grip = document.createElement('a');
grip.appendChild(document.createElement('uui-symbol-more'));
grip.className = className;
grip.className = colSelected ? 'grip-column selected' : 'grip-column';
grip.setAttribute('popovertarget', colSelected ? 'table-column-menu' : '');
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
});
@@ -126,7 +133,16 @@ export const UmbTableCell = TableCell.extend({
},
addProseMirrorPlugins() {
const { editor } = this;
return [
UmbBubbleMenuPlugin(this.editor, {
unique: 'table-row-menu',
placement: 'left',
elementName: 'umb-tiptap-table-row-menu',
shouldShow(props) {
return isRowGripSelected(props);
},
}),
new Plugin({
props: {
decorations: (state) => {
@@ -145,12 +161,13 @@ export const UmbTableCell = TableCell.extend({
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
const className = rowSelected ? 'grip-row selected' : 'grip-row';
const grip = document.createElement('a');
grip.appendChild(document.createElement('uui-symbol-more'));
grip.className = className;
grip.className = rowSelected ? 'grip-row selected' : 'grip-row';
grip.setAttribute('popovertarget', rowSelected ? 'table-row-menu' : '');
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
@@ -449,7 +466,7 @@ const isColumnGripSelected = ({
return !!gripColumn;
};
export const isRowGripSelected = ({
const isRowGripSelected = ({
editor,
view,
state,
@@ -35,7 +35,9 @@ export * from './extensions/tiptap-figure.extension.js';
export * from './extensions/tiptap-span.extension.js';
export * from './extensions/tiptap-html-global-attributes.extension.js';
export * from './extensions/tiptap-text-direction-extension.js';
export * from './extensions/tiptap-text-indent-extension.js';
export * from './extensions/tiptap-trailing-node.extension.js';
export * from './extensions/tiptap-umb-bubble-menu.extension.js';
export * from './extensions/tiptap-umb-embedded-media.extension.js';
export * from './extensions/tiptap-umb-image.extension.js';
export * from './extensions/tiptap-umb-link.extension.js';
@@ -270,10 +270,6 @@ export const data: Array<UmbMockDataTypeModel> = [
alias: 'ignoreUserStartNodes',
value: false,
},
{
alias: 'showOpenButton',
value: true,
},
{
alias: 'filter',
value: '',
@@ -1037,6 +1033,7 @@ export const data: Array<UmbMockDataTypeModel> = [
'Umb.Tiptap.Table',
'Umb.Tiptap.TextAlign',
'Umb.Tiptap.TextDirection',
'Umb.Tiptap.TextIndent',
'Umb.Tiptap.Underline',
],
},
@@ -1069,6 +1066,7 @@ export const data: Array<UmbMockDataTypeModel> = [
'Umb.Tiptap.Toolbar.TextDirectionRtl',
'Umb.Tiptap.Toolbar.TextDirectionLtr',
],
['Umb.Tiptap.Toolbar.TextIndent', 'Umb.Tiptap.Toolbar.TextOutdent'],
[
'Umb.Tiptap.Toolbar.BulletList',
'Umb.Tiptap.Toolbar.OrderedList',
@@ -2,10 +2,7 @@ import { UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS } from '../property-editors/con
import { manifests as blockManifests } from './block/manifests.js';
import { manifests as gridBlockManifests } from './grid-block/manifests.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import {
UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS,
UMB_WRITABLE_PROPERTY_CONDITION_ALIAS,
} from '@umbraco-cms/backoffice/property';
import { UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/property';
const forPropertyEditorUis = [UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS];
@@ -24,9 +21,6 @@ export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> =
name: 'Block Grid Copy To Clipboard Property Action',
forPropertyEditorUis,
conditions: [
{
alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS,
},
{
alias: UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS,
},
@@ -23,9 +23,6 @@ export const manifests: Array<UmbExtensionManifest> = [
name: 'Block List Copy To Clipboard Property Action',
forPropertyEditorUis,
conditions: [
{
alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS,
},
{
alias: UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS,
},
@@ -68,6 +68,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
@state()
_showContentEdit = false;
@state()
_hasSettings = false;
@@ -426,7 +427,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
look="secondary"
color=${this._contentInvalid ? 'danger' : ''}
href=${this._workspaceEditContentPath}>
<uui-icon name=${this._exposed === false ? 'icon-add' : 'icon-edit'}></uui-icon>
<uui-icon name=${this._exposed === false && this._isReadOnly === false ? 'icon-add' : 'icon-edit'}></uui-icon>
${this._contentInvalid
? html`<uui-badge attention color="danger" label="Invalid content">!</uui-badge>`
: nothing}
@@ -195,7 +195,7 @@ export class UmbBlockElementManager<LayoutDataType extends UmbBlockLayoutBaseMod
throw new Error(`Editor Alias of "${property.dataType.unique}" not found.`);
}
const entry = { ...variantId.toObject(), alias, editorAlias, value } as UmbBlockDataValueModel<ValueType>;
const entry = { editorAlias, ...variantId.toObject(), alias, value } as UmbBlockDataValueModel<ValueType>;
const currentData = this.getData();
if (currentData) {
@@ -1,4 +1,5 @@
import type { UmbBlockElementManager } from './block-element-manager.js';
import { UMB_BLOCK_WORKSPACE_CONTEXT } from './block-workspace.context-token.js';
import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
@@ -23,5 +24,16 @@ export class UmbBlockElementPropertyDatasetContext
this.getName = elementManager.getName;
this.culture = createObservablePart(elementManager.variantId, (v) => v?.culture);
this.segment = createObservablePart(elementManager.variantId, (v) => v?.segment);
this.consumeContext(UMB_BLOCK_WORKSPACE_CONTEXT, (workspace) => {
this.observe(
workspace.readOnlyState.states,
(states) => {
const isReadOnly = states.some((state) => state.variantId.equal(elementManager.getVariantId()));
this._readOnly.setValue(isReadOnly);
},
'umbObserveReadOnlyStates',
);
});
}
}
@@ -78,10 +78,12 @@ export class UmbBlockWorkspaceViewEditTabElement extends UmbLitElement {
override render() {
return html`
${this._hasProperties
? html` <umb-block-workspace-view-edit-properties
.managerName=${this.#managerName}
data-mark="property-group:root"
.containerId=${this._containerId}></umb-block-workspace-view-edit-properties>`
? html`<uui-box>
<umb-block-workspace-view-edit-properties
.managerName=${this.#managerName}
data-mark="property-group:root"
.containerId=${this._containerId}></umb-block-workspace-view-edit-properties>
</uui-box>`
: ''}
${this.hideSingleGroup && this._groups.length === 1
? this.renderGroup(this._groups[0])
@@ -149,7 +149,7 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin(
return html`
<umb-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<div id="item">
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-navigation"></uui-icon>`}
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-grib"></uui-icon>`}
<div class="color-wrapper">
<uui-input
id="input"
@@ -85,7 +85,7 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlMixin(U
override render() {
return html`
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-navigation" class="handle"></uui-icon>`}
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-grib" class="handle"></uui-icon>`}
<umb-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<uui-input
@@ -136,7 +136,11 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlMixin(U
}
.handle {
cursor: move;
cursor: grab;
}
.handle:active {
cursor: grabbing;
}
`,
];
@@ -160,10 +160,7 @@ export class UmbContentTypeWorkspaceViewEditGroupElement extends UmbLitElement {
return html`
<div slot="header" class="drag-handle">
<div>
${when(
this.sortModeActive && this._hasOwnerContainer,
() => html`<uui-icon name="icon-navigation"></uui-icon>`,
)}
${when(this.sortModeActive && this._hasOwnerContainer, () => html`<uui-icon name="icon-grib"></uui-icon>`)}
<uui-input
id="group-name"
label=${this.localize.term('contentTypeEditor_group')}
@@ -260,7 +260,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement {
if (!this.property) return;
return html`
<div class="sortable">
<uui-icon name="icon-navigation"></uui-icon>
<uui-icon name="icon-grib"></uui-icon>
<span>${this.property.name}</span>
<span style="color: var(--uui-color-disabled-contrast)">(${this.property.alias})</span>
</div>
@@ -173,7 +173,8 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement {
? html`
<uui-box class="${this._hasProperties ? '' : 'opaque'}">
<umb-content-type-design-editor-properties
.containerId=${this.containerId}></umb-content-type-design-editor-properties>
.containerId=${this.containerId}
.editContentTypePath=${this._editContentTypePath}></umb-content-type-design-editor-properties>
</uui-box>
`
: nothing
@@ -433,7 +433,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
`
: ''}
<uui-button look="outline" label=${sortButtonText} compact @click=${this.#toggleSortMode}>
<uui-icon name="icon-navigation"></uui-icon>
<uui-icon name="icon-height"></uui-icon>
${sortButtonText}
</uui-button>
</div>
@@ -498,7 +498,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
if (this._sortModeActive) {
return html`<div class="tab">
${ownedTab
? html`<uui-icon name="icon-navigation" class="drag-${tab.id}"> </uui-icon>${tabName}
? html`<uui-icon name="icon-grib" class="drag-${tab.id}"> </uui-icon>${tabName}
<uui-input
label="sort order"
type="number"
@@ -44,8 +44,8 @@ export abstract class UmbElementPropertyDatasetContext<
#propertyVariantIdMap = new UmbBasicState<UmbPropertyVariantIdMapType>([]);
private readonly _propertyVariantIdMap = this.#propertyVariantIdMap.asObservable();
#readOnly = new UmbBooleanState(false);
public readOnly = this.#readOnly.asObservable();
protected _readOnly = new UmbBooleanState(false);
public readOnly = this._readOnly.asObservable();
getEntityType(): string {
return this._dataOwner.getEntityType();
@@ -56,7 +56,7 @@ export abstract class UmbElementPropertyDatasetContext<
abstract getName(): string | undefined;
getReadOnly() {
return this.#readOnly.getValue();
return this._readOnly.getValue();
}
constructor(host: UmbControllerHost, dataOwner: DataOwnerType, variantId?: UmbVariantId) {
@@ -73,7 +73,7 @@ export abstract class UmbElementPropertyDatasetContext<
this._dataOwner.readOnlyState.states,
(states) => {
const isReadOnly = states.some((state) => state.variantId.equal(this.#variantId));
this.#readOnly.setValue(isReadOnly);
this._readOnly.setValue(isReadOnly);
},
null,
);
@@ -479,7 +479,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
}
// Notice the order of the properties is important for our JSON String Compare function. [NL]
const entry = { editorAlias, alias, ...variantId.toObject(), value } as UmbElementValueModel;
const entry = { editorAlias, ...variantId.toObject(), alias, value } as UmbElementValueModel;
const currentData = this.getData();
if (currentData) {
@@ -892,6 +892,11 @@
"name": "icon-grid",
"file": "grid-3x3.svg"
},
{
"name": "icon-drag",
"file": "grip-vertical.svg",
"internal": true
},
{
"name": "icon-hammer",
"file": "hammer.svg"
@@ -698,6 +698,10 @@ path: () => import("./icons/icon-graduate.js"),
name: "icon-grid",
path: () => import("./icons/icon-grid.js"),
},{
name: "icon-drag",
hidden: true,
path: () => import("./icons/icon-drag.js"),
},{
name: "icon-hammer",
path: () => import("./icons/icon-hammer.js"),
},{
@@ -0,0 +1 @@
export default `<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" class="lucide lucide-grip-vertical" viewBox="0 0 24 24"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>`;
@@ -115,7 +115,7 @@ export class UmbPickerInputContext<
color: 'danger',
headline: `Remove ${item.name}?`,
content: 'Are you sure you want to remove this item',
confirmLabel: 'Remove',
confirmLabel: '#actions_remove',
});
this.#removeItem(unique);
@@ -5,8 +5,7 @@ import { html, customElement, state, nothing, css } from '@umbraco-cms/backoffic
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
const elementName = 'umb-picker-search-field';
@customElement(elementName)
@customElement('umb-picker-search-field')
export class UmbPickerSearchFieldElement extends UmbLitElement {
@state()
_query: string = '';
@@ -66,6 +65,11 @@ export class UmbPickerSearchFieldElement extends UmbLitElement {
width: 100%;
}
uui-input [slot='prepend'] {
display: flex;
align-items: center;
}
#divider {
width: 100%;
height: 1px;
@@ -84,6 +88,6 @@ export class UmbPickerSearchFieldElement extends UmbLitElement {
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPickerSearchFieldElement;
'umb-picker-search-field': UmbPickerSearchFieldElement;
}
}
@@ -6,8 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
const elementName = 'umb-picker-search-result';
@customElement(elementName)
@customElement('umb-picker-search-result')
export class UmbPickerSearchResultElement extends UmbLitElement {
@state()
_query?: UmbSearchRequestArgs;
@@ -67,6 +66,6 @@ export class UmbPickerSearchResultElement extends UmbLitElement {
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPickerSearchResultElement;
'umb-picker-search-result': UmbPickerSearchResultElement;
}
}
@@ -11,7 +11,7 @@ export class UmbTemporaryFileConfigRepository extends UmbRepositoryBase implemen
/**
* Promise that resolves when the repository has been initialized, i.e. when the configuration has been fetched from the server.
*/
initialized: Promise<void>;
initialized;
#dataStore?: typeof UMB_TEMPORARY_FILE_CONFIG_STORE_CONTEXT.TYPE;
#dataSource = new UmbTemporaryFileConfigServerDataSource(this);
@@ -22,6 +22,15 @@ export class UmbTemporaryFileManager<
readonly #queue = new UmbArrayState<UploadableItem>([], (item) => item.temporaryUnique);
public readonly queue = this.#queue.asObservable();
/**
* Gets the temporary file configuration.
* @returns {Promise<UmbTemporaryFileConfigRepository>} The temporary file configuration.
*/
async getConfiguration(): Promise<UmbTemporaryFileConfigRepository> {
await this.#temporaryFileConfigRepository.initialized;
return this.#temporaryFileConfigRepository;
}
async uploadOne(uploadableItem: UploadableItem, options?: UmbUploadOptions<UploadableItem>): Promise<UploadableItem> {
this.#queue.setValue([]);
@@ -78,7 +87,8 @@ export class UmbTemporaryFileManager<
}
async #validateItem(item: UploadableItem): Promise<boolean> {
let maxFileSize = await this.observe(this.#temporaryFileConfigRepository.part('maxFileSize')).asPromise();
const config = await this.getConfiguration();
let maxFileSize = await this.observe(config.part('maxFileSize')).asPromise();
if (maxFileSize) {
// Convert from kilobytes to bytes
maxFileSize *= 1024;
@@ -289,7 +289,7 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
#renderChild(item: UmbTreeItemModel) {
// TODO: find a way to get the icon for the item. We do not have the icon in the tree item model.
return html` <uui-table-row id="content-node" data-unique=${item.unique} class="${this._isSorting ? 'hidden' : ''}">
<uui-table-cell><umb-icon name="icon-navigation"></umb-icon></uui-table-cell>
<uui-table-cell class="handle"><umb-icon name="icon-grib"></umb-icon></uui-table-cell>
<uui-table-cell>${item.name}</uui-table-cell>
<uui-table-cell>${this.#renderCreateDate(item)}</uui-table-cell>
</uui-table-row>`;
@@ -341,8 +341,8 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
cursor: grab;
}
uui-icon[name='icon-navigation'] {
cursor: hand;
uui-table-row[id='content-node']:active {
cursor: grabbing;
}
uui-box {
@@ -93,9 +93,9 @@ export class UmbDocumentBlueprintServerDataSource implements UmbDetailDataSource
values: data.values.map((value) => {
return {
editorAlias: value.editorAlias,
alias: value.alias,
culture: value.culture || null,
segment: value.segment || null,
alias: value.alias,
value: value.value,
};
}),
@@ -11,12 +11,6 @@ export const manifest: ManifestPropertyEditorUi = {
group: 'advanced',
settings: {
properties: [
{
alias: 'showOpenButton',
label: 'Show open button',
description: 'Opens the node in a dialog',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
},
{
alias: 'onlyPickElementTypes',
label: 'Only Element Types',
@@ -21,7 +21,6 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement
this.max = minMax?.max ?? Infinity;
this.onlyElementTypes = config.getValueByAlias('onlyPickElementTypes') ?? false;
this.showOpenButton = config?.getValueByAlias('showOpenButton') ?? false;
}
@state()
@@ -30,9 +29,6 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement
@state()
max = Infinity;
@state()
showOpenButton?: boolean;
@state()
onlyElementTypes?: boolean;
@@ -48,7 +44,6 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement
.max=${this.max}
.value=${this.value}
.elementTypesOnly=${this.onlyElementTypes ?? false}
?showOpenButton=${this.showOpenButton}
@change=${this.#onChange}>
</umb-input-document-type>
`;
@@ -183,6 +183,7 @@ export class UmbDocumentHistoryWorkspaceInfoAppElement extends UmbLitElement {
}
.log-type uui-tag {
justify-self: center;
height: fit-content;
margin-top: auto;
margin-bottom: auto;
@@ -46,7 +46,7 @@ export class UmbDocumentPickerInputContext extends UmbPickerInputContext<
...pickerData?.search?.queryParams,
};
super.openPicker(combinedPickerData);
await super.openPicker(combinedPickerData);
}
#pickableFilter = (
@@ -89,9 +89,6 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
@property({ type: Array })
allowedContentTypeIds?: string[] | undefined;
@property({ type: Boolean })
showOpenButton?: boolean;
@property({ type: String })
public override set value(selectionString: string | undefined) {
this.selection = splitStringToArray(selectionString);
@@ -26,12 +26,6 @@ export const manifests: Array<UmbExtensionManifest> = [
},
],
},
{
alias: 'showOpenButton',
label: 'Show open button',
description: 'Opens the node in a dialog',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
},
],
},
},
@@ -25,7 +25,6 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl
}
this._startNodeId = config.getValueByAlias('startNodeId');
this._showOpenButton = config.getValueByAlias('showOpenButton') ?? false;
}
/**
@@ -48,9 +47,6 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl
@state()
private _startNodeId?: string;
@state()
private _showOpenButton?: boolean;
#onChange(event: CustomEvent & { target: UmbInputDocumentElement }) {
this.value = event.target.value;
this.dispatchEvent(new UmbChangeEvent());
@@ -67,7 +63,6 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl
.max=${this._max}
.startNode=${startNode}
.value=${this.value}
?showOpenButton=${this._showOpenButton}
@change=${this.#onChange}
?readonly=${this.readonly}>
</umb-input-document>
@@ -137,9 +137,9 @@ export class UmbDocumentPublishingServerDataSource {
values: data.values.map((value) => {
return {
editorAlias: value.editorAlias,
alias: value.alias,
culture: value.culture || null,
segment: value.segment || null,
alias: value.alias,
value: value.value,
};
}),
@@ -94,9 +94,9 @@ export class UmbDocumentServerDataSource implements UmbDetailDataSource<UmbDocum
values: data.values.map((value) => {
return {
editorAlias: value.editorAlias,
alias: value.alias,
culture: value.culture || null,
segment: value.segment || null,
alias: value.alias,
value: value.value,
};
}),
@@ -2,7 +2,7 @@ import type { UmbDocumentUserPermissionModel } from '../types.js';
import { UmbDocumentItemRepository } from '../../item/index.js';
import type { UmbDocumentItemModel } from '../../item/types.js';
import { UMB_DOCUMENT_PICKER_MODAL } from '../../constants.js';
import { css, customElement, html, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
@@ -27,6 +27,9 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro
this.#observePickedDocuments(uniques);
}
@property({ type: Array, attribute: false })
fallbackPermissions: Array<string> = [];
@state()
private _items?: Array<UmbDocumentItemModel>;
@@ -122,15 +125,18 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro
// TODO: get correct variant name
const name = item.variants[0]?.name;
const headline = name ? `Permissions for ${name}` : 'Permissions';
const fallbackVerbs = this.#getFallbackPermissionVerbsForEntityType(item.entityType);
this.#entityUserPermissionModalContext = this.#modalManagerContext?.open(this, UMB_ENTITY_USER_PERMISSION_MODAL, {
data: {
unique: item.unique,
entityType: item.entityType,
headline,
preset: {
allowedVerbs: fallbackVerbs,
},
},
value: {
allowedVerbs,
allowedVerbs: allowedVerbs,
},
});
@@ -224,10 +230,8 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro
if (!permission) return;
return umbExtensionsRegistry
.getAllExtensions()
.filter((manifest) => manifest.type === 'entityUserPermission')
.filter((manifest) =>
(manifest as ManifestEntityUserPermission).meta.verbs.every((verb) => permission.verbs.includes(verb)),
.getByTypeAndFilter('entityUserPermission', (manifest) =>
manifest.meta.verbs.every((verb) => permission.verbs.includes(verb)),
)
.map((m) => {
const manifest = m as ManifestEntityUserPermission;
@@ -236,6 +240,22 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro
.join(', ');
}
#getFallbackPermissionVerbsForEntityType(entityType: string) {
// get all permissions that are allowed for the entity type and have at least one of the fallback permissions
// this is used to determine the default permissions for a document
const verbs = umbExtensionsRegistry
.getByTypeAndFilter(
'entityUserPermission',
(manifest) =>
manifest.forEntityTypes.includes(entityType) &&
this.fallbackPermissions.map((verb) => manifest.meta.verbs.includes(verb)).includes(true),
)
.flatMap((permission) => permission.meta.verbs);
// ensure that the verbs are unique
return [...new Set([...verbs])];
}
static override styles = [
css`
#btn-add {
@@ -1,6 +1,6 @@
import type { UmbLanguageItemModel } from '../../types.js';
import { UmbLanguagePickerInputContext } from './input-language.context.js';
import { css, html, customElement, property, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit';
import { css, html, customElement, property, state, repeat, nothing, when } from '@umbraco-cms/backoffice/external/lit';
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@@ -91,6 +91,27 @@ export class UmbInputLanguageElement extends UUIFormControlMixin(UmbLitElement,
return this.selection.join(',');
}
/**
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
* @type {boolean}
* @attr
* @default
*/
@property({ type: Boolean, reflect: true })
public get readonly() {
return this.#readonly;
}
public set readonly(value) {
this.#readonly = value;
if (this.#readonly) {
this.#sorter.disable();
} else {
this.#sorter.enable();
}
}
#readonly = false;
@state()
private _items: Array<UmbLanguageItemModel> = [];
@@ -126,7 +147,7 @@ export class UmbInputLanguageElement extends UUIFormControlMixin(UmbLitElement,
});
}
#removeItem(item: UmbLanguageItemModel) {
#onRemove(item: UmbLanguageItemModel) {
this.#pickerContext.requestRemoveItem(item.unique);
}
@@ -146,30 +167,34 @@ export class UmbInputLanguageElement extends UUIFormControlMixin(UmbLitElement,
}
#renderItems() {
if (!this._items) return nothing;
if (!this._items) return;
return html`
<uui-ref-list>
${repeat(
this._items,
(item) => item.unique,
(item) => this.#renderItem(item),
(item) =>
html`<umb-entity-item-ref
id=${item.unique}
.item=${item}
?readonly=${this.readonly}
?standalone=${this.max === 1}>
${when(
!this.readonly,
() => html`
<uui-action-bar slot="actions">
<uui-button
label=${this.localize.term('general_remove')}
@click=${() => this.#onRemove(item)}></uui-button>
</uui-action-bar>
`,
)}
</umb-entity-item-ref>`,
)}
</uui-ref-list>
`;
}
#renderItem(item: UmbLanguageItemModel) {
if (!item.unique) return;
return html`
<!-- TODO: add language ref element -->
<uui-ref-node name=${item.name} id=${item.unique} detail=${item.unique}>
<uui-action-bar slot="actions">
<uui-button @click=${() => this.#removeItem(item)} label=${this.localize.term('general_remove')}></uui-button>
</uui-action-bar>
</uui-ref-node>
`;
}
static override styles = [
css`
#btn-add {
@@ -0,0 +1,64 @@
import type { UmbLanguageItemModel } from '../types.js';
import { UMB_LANGUAGE_ENTITY_TYPE } from '../entity.js';
import { UMB_EDIT_LANGUAGE_WORKSPACE_PATH_PATTERN } from '../paths.js';
import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
@customElement('umb-language-item-ref')
export class UmbLanguageItemRefElement extends UmbLitElement {
@property({ type: Object })
item?: UmbLanguageItemModel;
@property({ type: Boolean })
readonly = false;
@property({ type: Boolean })
standalone = false;
@state()
_editPath = '';
constructor() {
super();
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addUniquePaths(['unique'])
.onSetup(() => {
return { data: { entityType: UMB_LANGUAGE_ENTITY_TYPE, preset: {} } };
})
.observeRouteBuilder((routeBuilder) => {
this._editPath = routeBuilder({});
});
}
#getHref() {
if (!this.item?.unique) return;
const path = UMB_EDIT_LANGUAGE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.unique });
return `${this._editPath}/${path}`;
}
override render() {
if (!this.item) return nothing;
return html`
<uui-ref-node
name=${this.item.name}
href=${ifDefined(this.#getHref())}
?readonly=${this.readonly}
?standalone=${this.standalone}>
<slot name="actions" slot="actions"></slot>
<umb-icon slot="icon" name="icon-globe"></umb-icon>
</uui-ref-node>
`;
}
}
export { UmbLanguageItemRefElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-language-item-ref': UmbLanguageItemRefElement;
}
}
@@ -0,0 +1,11 @@
import { UMB_LANGUAGE_ENTITY_TYPE } from '../entity.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'entityItemRef',
alias: 'Umb.EntityItemRef.Language',
name: 'Language Entity Item Reference',
element: () => import('./langauge-item-ref.element.js'),
forEntityTypes: [UMB_LANGUAGE_ENTITY_TYPE],
},
];
@@ -1,23 +1,25 @@
import { manifests as repositoryManifests } from './repository/manifests.js';
import { manifests as menuManifests } from './menu/manifests.js';
import { manifests as entityActions } from './entity-actions/manifests.js';
import { manifests as workspaceManifests } from './workspace/manifests.js';
import { manifests as appLanguageSelect } from './app-language-select/manifests.js';
import { manifests as modalManifests } from './modals/manifests.js';
import { manifests as collectionManifests } from './collection/manifests.js';
import { manifests as globalContextManifests } from './global-contexts/manifests.js';
import { manifest as conditionsManifest } from './conditions/multiple-app-languages.condition.js';
import { manifests as appLanguageSelect } from './app-language-select/manifests.js';
import { manifests as collectionManifests } from './collection/manifests.js';
import { manifests as entityActions } from './entity-actions/manifests.js';
import { manifests as globalContextManifests } from './global-contexts/manifests.js';
import { manifests as itemManifests } from './item/manifests.js';
import { manifests as menuManifests } from './menu/manifests.js';
import { manifests as modalManifests } from './modals/manifests.js';
import { manifests as repositoryManifests } from './repository/manifests.js';
import { manifests as workspaceManifests } from './workspace/manifests.js';
import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace';
export const manifests: Array<UmbExtensionManifest> = [
...repositoryManifests,
...entityActions,
...menuManifests,
...workspaceManifests,
...appLanguageSelect,
...modalManifests,
...collectionManifests,
...entityActions,
...globalContextManifests,
...itemManifests,
...menuManifests,
...modalManifests,
...repositoryManifests,
...workspaceManifests,
conditionsManifest,
{
type: 'workspaceContext',
@@ -1,3 +1,4 @@
import { UMB_LANGUAGE_ENTITY_TYPE } from '../../entity.js';
import type { UmbLanguageItemModel } from './types.js';
import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository';
import type { LanguageItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
@@ -33,5 +34,6 @@ const mapper = (item: LanguageItemResponseModel): UmbLanguageItemModel => {
return {
unique: item.isoCode,
name: item.name,
entityType: UMB_LANGUAGE_ENTITY_TYPE,
};
};
@@ -1,4 +1,7 @@
import type { UmbLanguageEntityType } from '../../entity.js';
export interface UmbLanguageItemModel {
entityType: UmbLanguageEntityType;
unique: string;
name: string;
}
@@ -237,7 +237,6 @@ export class UmbLogViewerMessageElement extends UmbLitElement {
pre {
border-left: 4px solid #d42054;
color: #303033;
display: block;
font-family:
Lato,
@@ -9,15 +9,5 @@ export const manifest: ManifestPropertyEditorUi = {
label: 'Media Type Picker',
icon: 'icon-media-dashed-line',
group: 'advanced',
settings: {
properties: [
{
alias: 'showOpenButton',
label: 'Show open button',
description: 'Opens the node in a dialog',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
},
],
},
},
};
@@ -148,6 +148,10 @@ export class UmbImageCropperFocusSetterElement extends UmbLitElement {
#handleGridDrag(event: PointerEvent) {
if (this.disabled || this.hideFocalPoint) return;
if (event.button !== 0) {
// This is not a primary mouse button click, so lets not do anything.
return;
}
const grid = this.wrapperElement;
const handle = this.focalPointElement;
@@ -177,6 +181,31 @@ export class UmbImageCropperFocusSetterElement extends UmbLitElement {
});
}
#changeFocalPoint(event: PointerEvent) {
if (this.disabled || this.hideFocalPoint) return;
if (event.button !== 0) {
// This is not a primary mouse button click, so lets not do anything.
return;
}
const grid = this.wrapperElement;
const handle = this.focalPointElement;
if (!grid) return;
handle?.focus();
const dims = grid.getBoundingClientRect();
const defaultView = grid.ownerDocument.defaultView!;
const { width, height } = grid.getBoundingClientRect();
const offsetX = dims.left + defaultView.scrollX;
const offsetY = dims.top + defaultView.scrollY;
const x = event.pageX - offsetX;
const y = event.pageY - offsetY;
this.#setFocalPoint(x, y, width, height);
}
#handleGridKeyDown(event: KeyboardEvent) {
if (this.disabled || this.hideFocalPoint) return;
@@ -215,7 +244,11 @@ export class UmbImageCropperFocusSetterElement extends UmbLitElement {
override render() {
if (!this.src) return nothing;
return html`
<div id="wrapper" @mousedown=${this.#handleGridDrag} @touchstart=${this.#handleGridDrag}>
<div
id="wrapper"
@click=${this.#changeFocalPoint}
@mousedown=${this.#handleGridDrag}
@touchstart=${this.#handleGridDrag}>
<img id="image" @keydown=${() => nothing} src=${this.src} alt="" />
<span
id="focal-point"
@@ -1,18 +1,18 @@
import type { UmbImageCropperPropertyEditorValue } from './types.js';
import type { UmbInputImageCropperFieldElement } from './image-cropper-field.element.js';
import { html, customElement, property, query, state, css } from '@umbraco-cms/backoffice/external/lit';
import { html, customElement, property, query, state, css, ifDefined } from '@umbraco-cms/backoffice/external/lit';
import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';
import { assignToFrozenObject } from '@umbraco-cms/backoffice/observable-api';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import './image-cropper.element.js';
import './image-cropper-focus-setter.element.js';
import './image-cropper-preview.element.js';
import './image-cropper-field.element.js';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
const DefaultFocalPoint = { left: 0.5, top: 0.5 };
const DefaultValue = {
@@ -50,11 +50,16 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
@state()
fileUnique?: string;
#manager?: UmbTemporaryFileManager;
@state()
private _accept?: string;
@state()
private _loading = true;
#manager = new UmbTemporaryFileManager(this);
constructor() {
super();
this.#manager = new UmbTemporaryFileManager(this);
this.addValidator(
'valueMissing',
@@ -67,6 +72,19 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
protected override firstUpdated(): void {
this.#mergeCrops();
this.#observeAcceptedFileTypes();
}
async #observeAcceptedFileTypes() {
const config = await this.#manager.getConfiguration();
this.observe(
config.part('imageFileTypes'),
(imageFileTypes) => {
this._accept = imageFileTypes.join(',');
this._loading = false;
},
'_observeFileTypes',
);
}
#onUpload(e: UUIFileDropzoneEvent) {
@@ -122,6 +140,10 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
}
override render() {
if (this._loading) {
return html`<div id="loader"><uui-loader></uui-loader></div>`;
}
if (this.value?.src || this.file) {
return this.#renderImageCropper();
}
@@ -131,7 +153,12 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
#renderDropzone() {
return html`
<uui-file-dropzone id="dropzone" label="dropzone" @change="${this.#onUpload}" @click=${this.#onBrowse}>
<uui-file-dropzone
id="dropzone"
label="dropzone"
accept=${ifDefined(this._accept)}
@change="${this.#onUpload}"
@click=${this.#onBrowse}>
<uui-button label=${this.localize.term('media_clickToUpload')} @click="${this.#onBrowse}"></uui-button>
</uui-file-dropzone>
`;
@@ -166,6 +193,11 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
static override styles = [
css`
#loader {
display: flex;
justify-content: center;
}
uui-file-dropzone {
position: relative;
display: block;
@@ -44,7 +44,7 @@ export class UmbMediaPickerInputContext extends UmbPickerInputContext<
...pickerData?.search?.queryParams,
};
super.openPicker(combinedPickerData);
await super.openPicker(combinedPickerData);
}
#pickableFilter = (
@@ -110,9 +110,6 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
@property({ type: Array })
allowedContentTypeIds?: Array<string> | undefined;
@property({ type: Boolean })
showOpenButton?: boolean;
@property({ type: Object, attribute: false })
startNode?: UmbTreeStartNode;
@@ -424,6 +424,10 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<UmbMediaPick
width: 100%;
margin-bottom: var(--uui-size-3);
}
#search uui-input [slot='prepend'] {
display: flex;
align-items: center;
}
#searching-indicator {
margin-left: 7px;
@@ -193,7 +193,7 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen
(item) => item.alias,
(item) => html`
<div class="crop" data-alias="${item.alias}">
<span class="crop-drag">+</span>
<uui-icon name="icon-grib" class="crop-drag"></uui-icon>
<span><strong>${item.label}</strong> <em>(${item.alias})</em></span>
<span class="crop-size">(${item.width} x ${item.height}px)</span>
<div class="crop-actions">
@@ -236,6 +236,11 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen
color: var(--uui-color-disabled-contrast);
font-weight: bold;
}
.crop-drag:active {
cursor: grabbing;
}
.crop-size {
font-size: 0.9em;
padding-inline: var(--uui-size-space-4);
@@ -83,9 +83,6 @@ export class UmbInputMemberGroupElement extends UmbFormControlMixin<string | und
return this.#pickerContext.getSelection();
}
@property({ type: Boolean })
showOpenButton?: boolean;
@property({ type: Array })
allowedContentTypeIds?: string[] | undefined;
@@ -50,7 +50,6 @@ export class UmbPropertyEditorUIMemberGroupPickerElement extends UmbLitElement i
.min=${this._min}
.max=${this._max}
.value=${this.value}
?showOpenButton=${true}
@change=${this.#onChange}
?readonly=${this.readonly}></umb-input-member-group>
`;
@@ -48,7 +48,7 @@ export class UmbMemberPickerInputContext extends UmbPickerInputContext<
...pickerData?.search?.queryParams,
};
super.openPicker(combinedPickerData);
await super.openPicker(combinedPickerData);
}
#pickableFilter = (
@@ -56,17 +56,15 @@ export class UmbModelsBuilderDashboardElement extends UmbLitElement {
override render() {
return html`
<uui-box class="uui-text">
<div class="headline">
<h1 class="uui-h2">Models Builder</h1>
<uui-button
.state="${this._buttonStateReload}"
look="secondary"
label="Reload"
@click="${this._onDashboardReload}">
<uui-box headline="Models Builder" class="overview">
<uui-button
slot="header-actions"
.state="${this._buttonStateReload}"
look="secondary"
label="Reload"
@click="${this._onDashboardReload}">
Reload
</uui-button>
</div>
<p>Version: ${this._modelsBuilder?.version}</p>
<div class="models-description">
<p>ModelsBuilder is enabled with the following configuration:</p>
@@ -138,12 +136,6 @@ export class UmbModelsBuilderDashboardElement extends UmbLitElement {
padding: var(--uui-size-layout-1);
}
.headline {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.models-description ul {
list-style-type: square;
margin: 0;
@@ -263,7 +263,6 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
<umb-input-document
?hidden=${!this.value.link.unique || this.value.link.type !== 'document'}
.max=${1}
.showOpenButton=${true}
.value=${this.value.link.unique && this.value.link.type === 'document' ? this.value.link.unique : ''}
@change=${(e: UmbInputPickerEvent) => this.#onPickerSelection(e, 'document')}>
</umb-input-document>
@@ -132,7 +132,7 @@ export class UmbPropertyEditorUICollectionColumnConfigurationElement
#renderColumn(column: UmbCollectionColumnConfiguration) {
return html`
<div class="layout-item" id=${column.alias}>
<uui-icon name="icon-navigation"></uui-icon>
<uui-icon class="drag-handle" name="icon-grib"></uui-icon>
<uui-input
required
@@ -193,6 +193,14 @@ export class UmbPropertyEditorUICollectionColumnConfigurationElement
display: flex;
justify-content: flex-end;
}
.drag-handle {
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
`,
];
}
@@ -158,7 +158,7 @@ export class UmbPropertyEditorUICollectionLayoutConfigurationElement
const varName = icon.color ? extractUmbColorVariable(icon.color) : undefined;
return html`
<div class="layout-item" id=${this.#getUnique(layout)}>
<uui-icon name="icon-navigation"></uui-icon>
<uui-icon class="drag-handle" name="icon-grib"></uui-icon>
<uui-button compact look="outline" label="pick icon" @click=${() => this.#onIconChange(icon, index)}>
${when(
@@ -224,6 +224,14 @@ export class UmbPropertyEditorUICollectionLayoutConfigurationElement
display: flex;
justify-content: flex-end;
}
.drag-handle {
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
`,
];
}

Some files were not shown because too many files have changed in this diff Show More