diff --git a/src/SolutionInfo.cs b/src/SolutionInfo.cs index f829770a59..1c59137aac 100644 --- a/src/SolutionInfo.cs +++ b/src/SolutionInfo.cs @@ -18,5 +18,5 @@ using System.Resources; [assembly: AssemblyVersion("8.0.0")] // these are FYI and changed automatically -[assembly: AssemblyFileVersion("8.9.0")] -[assembly: AssemblyInformationalVersion("8.9.0-rc")] +[assembly: AssemblyFileVersion("8.9.1")] +[assembly: AssemblyInformationalVersion("8.9.1")] diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 64216ba571..80906a0663 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -17,6 +17,11 @@ /// The preview cookie name /// public const string PreviewCookieName = "UMB_PREVIEW"; + /// + + /// Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. + /// + public const string AcceptPreviewCookieName = "UMB-WEBSITE-PREVIEW-ACCEPT"; public const string InstallerCookieName = "umb_installId"; } diff --git a/src/Umbraco.Core/Packaging/PackageDataInstallation.cs b/src/Umbraco.Core/Packaging/PackageDataInstallation.cs index 6a5acb0dc7..1320f33a50 100644 --- a/src/Umbraco.Core/Packaging/PackageDataInstallation.cs +++ b/src/Umbraco.Core/Packaging/PackageDataInstallation.cs @@ -604,6 +604,8 @@ namespace Umbraco.Core.Packaging var defaultTemplateElement = infoElement.Element("DefaultTemplate"); contentType.Name = infoElement.Element("Name").Value; + if (infoElement.Element("Key") != null) + contentType.Key = new Guid(infoElement.Element("Key").Value); contentType.Icon = infoElement.Element("Icon").Value; contentType.Thumbnail = infoElement.Element("Thumbnail").Value; contentType.Description = infoElement.Element("Description").Value; @@ -800,6 +802,8 @@ namespace Umbraco.Core.Packaging ? (ContentVariation)Enum.Parse(typeof(ContentVariation), property.Element("Variations").Value) : ContentVariation.Nothing }; + if (property.Element("Key") != null) + propertyType.Key = new Guid(property.Element("Key").Value); var tab = (string)property.Element("Tab"); if (string.IsNullOrEmpty(tab)) diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs index 845006891d..a7b23d95cb 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -980,6 +980,81 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } + /// + /// Inserts property values for the content entity + /// + /// + /// + /// + /// + /// + /// Used when creating a new entity + /// + protected void InsertPropertyValues(TEntity entity, int publishedVersionId, out bool edited, out HashSet editedCultures) + { + // persist the property data + var propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishedVersionId, entity.Properties, LanguageRepository, out edited, out editedCultures); + foreach (var propertyDataDto in propertyDataDtos) + { + Database.Insert(propertyDataDto); + } + // TODO: we can speed this up: Use BulkInsert and then do one SELECT to re-retrieve the property data inserted with assigned IDs. + // This is a perfect thing to benchmark with Benchmark.NET to compare perf between Nuget releases. + } + + /// + /// Used to atomically replace the property values for the entity version specified + /// + /// + /// + /// + /// + /// + + protected void ReplacePropertyValues(TEntity entity, int versionId, int publishedVersionId, out bool edited, out HashSet editedCultures) + { + // Replace the property data. + // Lookup the data to update with a UPDLOCK (using ForUpdate()) this is because we need to be atomic + // and handle DB concurrency. Doing a clear and then re-insert is prone to concurrency issues. + + var propDataSql = SqlContext.Sql().Select("*").From().Where(x => x.VersionId == versionId).ForUpdate(); + var existingPropData = Database.Fetch(propDataSql); + var propertyTypeToPropertyData = new Dictionary<(int propertyTypeId, int versionId, int? languageId, string segment), PropertyDataDto>(); + var existingPropDataIds = new List(); + foreach (var p in existingPropData) + { + existingPropDataIds.Add(p.Id); + propertyTypeToPropertyData[(p.PropertyTypeId, p.VersionId, p.LanguageId, p.Segment)] = p; + } + var propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishedVersionId, entity.Properties, LanguageRepository, out edited, out editedCultures); + + foreach (var propertyDataDto in propertyDataDtos) + { + + // Check if this already exists and update, else insert a new one + if (propertyTypeToPropertyData.TryGetValue((propertyDataDto.PropertyTypeId, propertyDataDto.VersionId, propertyDataDto.LanguageId, propertyDataDto.Segment), out var propData)) + { + propertyDataDto.Id = propData.Id; + Database.Update(propertyDataDto); + } + else + { + // TODO: we can speed this up: Use BulkInsert and then do one SELECT to re-retrieve the property data inserted with assigned IDs. + // This is a perfect thing to benchmark with Benchmark.NET to compare perf between Nuget releases. + Database.Insert(propertyDataDto); + } + + // track which ones have been processed + existingPropDataIds.Remove(propertyDataDto.Id); + } + // For any remaining that haven't been processed they need to be deleted + if (existingPropDataIds.Count > 0) + { + Database.Execute(SqlContext.Sql().Delete().WhereIn(x => x.Id, existingPropDataIds)); + } + + } + private class NodeIdKey { [Column("id")] diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs index e4ef939870..c230d9f894 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs @@ -610,17 +610,11 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.Insert(documentVersionDto); } - // replace the property data (rather than updating) + // replace the property data (rather than updating) // only need to delete for the version that existed, the new version (if any) has no property data yet - var versionToDelete = publishing ? entity.PublishedVersionId : entity.VersionId; - var deletePropertyDataSql = Sql().Delete().Where(x => x.VersionId == versionToDelete); - Database.Execute(deletePropertyDataSql); - - // insert property data - var propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishing ? entity.PublishedVersionId : 0, - entity.Properties, LanguageRepository, out var edited, out var editedCultures); - foreach (var propertyDataDto in propertyDataDtos) - Database.Insert(propertyDataDto); + var versionToDelete = publishing ? entity.PublishedVersionId : entity.VersionId; + // insert property data + ReplacePropertyValues(entity, versionToDelete, publishing ? entity.PublishedVersionId : 0, out var edited, out var editedCultures); // if !publishing, we may have a new name != current publish name, // also impacts 'edited' diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs index 00d1105965..02bef366cb 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs @@ -281,9 +281,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.Insert(mediaVersionDto); // persist the property data - var propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); - foreach (var propertyDataDto in propertyDataDtos) - Database.Insert(propertyDataDto); + InsertPropertyValues(entity, 0, out _, out _); // set tags SetEntityTags(entity, _tagRepository); @@ -346,11 +344,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.Update(mediaVersionDto); // replace the property data - var deletePropertyDataSql = SqlContext.Sql().Delete().Where(x => x.VersionId == entity.VersionId); - Database.Execute(deletePropertyDataSql); - var propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); - foreach (var propertyDataDto in propertyDataDtos) - Database.Insert(propertyDataDto); + ReplacePropertyValues(entity, entity.VersionId, 0, out _, out _); SetEntityTags(entity, _tagRepository); diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs index 687e35aef3..78dbbe317a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs @@ -245,8 +245,6 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } entity.AddingEntity(); - var member = (Member) entity; - // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? entity.SanitizeEntityPropertiesForXmlStorage(); @@ -304,7 +302,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement contentVersionDto.NodeId = nodeDto.NodeId; contentVersionDto.Current = true; Database.Insert(contentVersionDto); - member.VersionId = contentVersionDto.Id; + entity.VersionId = contentVersionDto.Id; // persist the member dto dto.NodeId = nodeDto.NodeId; @@ -321,9 +319,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.Insert(dto); // persist the property data - var propertyDataDtos = PropertyFactory.BuildDtos(member.ContentType.Variations, member.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); - foreach (var propertyDataDto in propertyDataDtos) - Database.Insert(propertyDataDto); + InsertPropertyValues(entity, 0, out _, out _); SetEntityTags(entity, _tagRepository); @@ -335,11 +331,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } protected override void PersistUpdatedItem(IMember entity) - { - var member = (Member) entity; - + { // update - member.UpdatingEntity(); + entity.UpdatingEntity(); // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? @@ -385,27 +379,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement if (changedCols.Count > 0) Database.Update(dto, changedCols); - // Replace the property data - // Lookup the data to update with a UPDLOCK (using ForUpdate()) this is because we have another method that doesn't take an explicit WriteLock - // in SetLastLogin which is called very often and we want to avoid the lock timeout for the explicit lock table but we still need to ensure atomic - // operations between that method and this one. - - var propDataSql = SqlContext.Sql().Select("*").From().Where(x => x.VersionId == member.VersionId).ForUpdate(); - var existingPropData = Database.Fetch(propDataSql).ToDictionary(x => x.PropertyTypeId); - var propertyDataDtos = PropertyFactory.BuildDtos(member.ContentType.Variations, member.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); - foreach (var propertyDataDto in propertyDataDtos) - { - // Check if this already exists and update, else insert a new one - if (existingPropData.TryGetValue(propertyDataDto.PropertyTypeId, out var propData)) - { - propertyDataDto.Id = propData.Id; - Database.Update(propertyDataDto); - } - else - { - Database.Insert(propertyDataDto); - } - } + ReplacePropertyValues(entity, entity.VersionId, 0, out _, out _); SetEntityTags(entity, _tagRepository); diff --git a/src/Umbraco.Core/Services/IIconService.cs b/src/Umbraco.Core/Services/IIconService.cs index 72174a0504..963edb22a5 100644 --- a/src/Umbraco.Core/Services/IIconService.cs +++ b/src/Umbraco.Core/Services/IIconService.cs @@ -9,13 +9,13 @@ namespace Umbraco.Core.Services /// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path /// /// - /// + /// IconModel GetIcon(string iconName); /// /// Gets a list of all svg icons found at at the global icons path. /// - /// A list of + /// IList GetAllIcons(); } } diff --git a/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs b/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs index d751a82dfb..76c0a631ba 100644 --- a/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs +++ b/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs @@ -434,6 +434,7 @@ namespace Umbraco.Core.Services.Implement var info = new XElement("Info", new XElement("Name", contentType.Name), new XElement("Alias", contentType.Alias), + new XElement("Key", contentType.Key), new XElement("Icon", contentType.Icon), new XElement("Thumbnail", contentType.Thumbnail), new XElement("Description", contentType.Description), @@ -484,8 +485,9 @@ namespace Umbraco.Core.Services.Implement var genericProperty = new XElement("GenericProperty", new XElement("Name", propertyType.Name), new XElement("Alias", propertyType.Alias), + new XElement("Key", propertyType.Key), new XElement("Type", propertyType.PropertyEditorAlias), - new XElement("Definition", definition.Key), + new XElement("Definition", definition.Key), new XElement("Tab", propertyGroup == null ? "" : propertyGroup.Name), new XElement("SortOrder", propertyType.SortOrder), new XElement("Mandatory", propertyType.Mandatory.ToString()), diff --git a/src/Umbraco.Examine/ContentIndexPopulator.cs b/src/Umbraco.Examine/ContentIndexPopulator.cs index 837c230313..e10ad012c4 100644 --- a/src/Umbraco.Examine/ContentIndexPopulator.cs +++ b/src/Umbraco.Examine/ContentIndexPopulator.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.Linq; using Examine; -using Umbraco.Core; using Umbraco.Core.Models; -using Umbraco.Core.Models.Blocks; using Umbraco.Core.Services; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.DatabaseModelDefinitions; diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts index 23e97043b0..68f31e80bb 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts @@ -1,11 +1,19 @@ /// -import { DocumentTypeBuilder, ContentBuilder } from 'umbraco-cypress-testhelpers'; +import { DocumentTypeBuilder, ContentBuilder, AliasHelper } from 'umbraco-cypress-testhelpers'; context('Content', () => { beforeEach(() => { cy.umbracoLogin(Cypress.env('username'), Cypress.env('password')); }); + function refreshContentTree(){ + // Refresh to update the tree + cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); + cy.umbracoContextMenuAction("action-refreshNode").click(); + // We have to wait in case the execution is slow, otherwise we'll try and click the item before it appears in the UI + cy.get('.umb-tree-item__inner').should('exist', {timeout: 10000}); + } + it('Copy content', () => { const rootDocTypeName = "Test document type"; const childDocTypeName = "Child test document type"; @@ -40,8 +48,8 @@ context('Content', () => { .withContentTypeAlias(rootDocTypeAlias) .withAction("saveNew") .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -52,8 +60,8 @@ context('Content', () => { .withAction("saveNew") .withParent(contentNode["id"]) .addVariant() - .withName(childNodeName) - .withSave(true) + .withName(childNodeName) + .withSave(true) .done() .build(); @@ -64,8 +72,8 @@ context('Content', () => { .withContentTypeAlias(rootDocTypeAlias) .withAction("saveNew") .addVariant() - .withName(anotherNodeName) - .withSave(true) + .withName(anotherNodeName) + .withSave(true) .done() .build(); @@ -74,8 +82,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Copy node cy.umbracoTreeItem("content", [nodeName, childNodeName]).rightclick({ force: true }); @@ -125,8 +132,8 @@ context('Content', () => { .withContentTypeAlias(rootDocTypeAlias) .withAction("saveNew") .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -137,8 +144,8 @@ context('Content', () => { .withAction("saveNew") .withParent(contentNode["id"]) .addVariant() - .withName(childNodeName) - .withSave(true) + .withName(childNodeName) + .withSave(true) .done() .build(); @@ -149,8 +156,8 @@ context('Content', () => { .withContentTypeAlias(rootDocTypeAlias) .withAction("saveNew") .addVariant() - .withName(anotherNodeName) - .withSave(true) + .withName(anotherNodeName) + .withSave(true) .done() .build(); @@ -159,8 +166,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Move node cy.umbracoTreeItem("content", [nodeName, childNodeName]).rightclick({ force: true }); @@ -209,8 +215,8 @@ context('Content', () => { .withContentTypeAlias(generatedRootDocType["alias"]) .withAction("saveNew") .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -223,8 +229,8 @@ context('Content', () => { .withAction("saveNew") .withParent(parentId) .addVariant() - .withName(firstChildNodeName) - .withSave(true) + .withName(firstChildNodeName) + .withSave(true) .done() .build(); @@ -236,8 +242,8 @@ context('Content', () => { .withAction("saveNew") .withParent(parentId) .addVariant() - .withName(secondChildNodeName) - .withSave(true) + .withName(secondChildNodeName) + .withSave(true) .done() .build(); @@ -247,8 +253,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Sort nodes cy.umbracoTreeItem("content", [nodeName]).rightclick({ force: true }); @@ -288,8 +293,8 @@ context('Content', () => { const rootContentNode = new ContentBuilder() .withContentTypeAlias(generatedRootDocType["alias"]) .addVariant() - .withName(initialNodeName) - .withSave(true) + .withName(initialNodeName) + .withSave(true) .done() .build(); @@ -297,8 +302,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Access node cy.umbracoTreeItem("content", [initialNodeName]).click(); @@ -309,15 +313,16 @@ context('Content', () => { // Save and publish cy.get('.btn-success').first().click(); + cy.umbracoSuccessNotification().should('be.visible'); // Rollback cy.get('.umb-box-header :button').click(); - cy.get('.umb-box-content > .ng-scope > .input-block-level') + cy.get('.umb-box-content > div > .input-block-level') .find('option[label*=' + new Date().getDate() + ']') .then(elements => { const option = elements[[elements.length - 1]].getAttribute('value'); - cy.get('.umb-box-content > .ng-scope > .input-block-level') + cy.get('.umb-box-content > div > .input-block-level') .select(option); }); @@ -326,8 +331,8 @@ context('Content', () => { cy.reload(); // Assert - cy.get('.history').find('.umb-badge').eq(0).should('contain.text', "Save"); - cy.get('.history').find('.umb-badge').eq(1).should('contain.text', "Rollback"); + cy.get('.history').find('.umb-badge').contains('Save').should('be.visible'); + cy.get('.history').find('.umb-badge').contains('Rollback').should('be.visible'); cy.get('#headerName').should('have.value', initialNodeName); // Clean up (content is automatically deleted when document types are gone) @@ -343,9 +348,9 @@ context('Content', () => { .withName(rootDocTypeName) .withAllowAsRoot(true) .addGroup() - .addTextBoxProperty() - .withLabel(labelName) - .done() + .addTextBoxProperty() + .withLabel(labelName) + .done() .done() .build(); @@ -356,8 +361,8 @@ context('Content', () => { const rootContentNode = new ContentBuilder() .withContentTypeAlias(generatedRootDocType["alias"]) .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -365,8 +370,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Access node cy.umbracoTreeItem("content", [nodeName]).click(); @@ -398,8 +402,8 @@ context('Content', () => { .withContentTypeAlias(generatedRootDocType["alias"]) .withAction("saveNew") .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -407,8 +411,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Access node cy.umbracoTreeItem("content", [nodeName]).click(); @@ -437,8 +440,8 @@ context('Content', () => { .withContentTypeAlias(generatedRootDocType["alias"]) .withAction("saveNew") .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -446,8 +449,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Access node cy.umbracoTreeItem("content", [nodeName]).click(); @@ -478,8 +480,8 @@ context('Content', () => { const rootContentNode = new ContentBuilder() .withContentTypeAlias(generatedRootDocType["alias"]) .addVariant() - .withName(nodeName) - .withSave(true) + .withName(nodeName) + .withSave(true) .done() .build(); @@ -487,8 +489,7 @@ context('Content', () => { }); // Refresh to update the tree - cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick(); - cy.umbracoContextMenuAction("action-refreshNode").click(); + refreshContentTree(); // Access node cy.umbracoTreeItem("content", [nodeName]).click(); @@ -499,4 +500,100 @@ context('Content', () => { // Clean up (content is automatically deleted when document types are gone) cy.umbracoEnsureDocumentTypeNameNotExists(rootDocTypeName); }); + + it('Content with contentpicker', () => { + const pickerDocTypeName = 'Content picker doc type'; + const pickerDocTypeAlias = AliasHelper.toAlias(pickerDocTypeName); + const pickedDocTypeName = 'Picked content document type'; + + cy.deleteAllContent(); + cy.umbracoEnsureDocumentTypeNameNotExists(pickerDocTypeName); + cy.umbracoEnsureTemplateNameNotExists(pickerDocTypeName); + cy.umbracoEnsureDocumentTypeNameNotExists(pickedDocTypeName); + + // Create the content type and content we'll be picking from. + const pickedDocType = new DocumentTypeBuilder() + .withName(pickedDocTypeName) + .withAllowAsRoot(true) + .addGroup() + .addTextBoxProperty() + .withAlias('text') + .done() + .done() + .build(); + + cy.saveDocumentType(pickedDocType).then((generatedType) => { + const pickedContentNode = new ContentBuilder() + .withContentTypeAlias(generatedType["alias"]) + .withAction("publishNew") + .addVariant() + .withName('Content to pick') + .withSave(true) + .withPublish(true) + .addProperty() + .withAlias('text') + .withValue('Acceptance test') + .done() + .withSave(true) + .withPublish(true) + .done() + .build(); + cy.saveContent(pickedContentNode); + }); + + // Create the doctype with a the picker + const pickerDocType = new DocumentTypeBuilder() + .withName(pickerDocTypeName) + .withAlias(pickerDocTypeAlias) + .withAllowAsRoot(true) + .withDefaultTemplate(pickerDocTypeAlias) + .addGroup() + .withName('ContentPickerGroup') + .addContentPickerProperty() + .withAlias('picker') + .done() + .done() + .build(); + + cy.saveDocumentType(pickerDocType); + + // Edit it the template to allow us to verify the rendered view. + cy.editTemplate(pickerDocTypeName, `@inherits Umbraco.Web.Mvc.UmbracoViewPage + @using ContentModels = Umbraco.Web.PublishedModels; + @{ + Layout = null; + } + + @{ + IPublishedContent typedContentPicker = Model.Value("picker"); + if (typedContentPicker != null) + { +

@typedContentPicker.Value("text")

+ } + }`); + + // Create content with content picker + cy.get('.umb-tree-root-link').rightclick(); + cy.get('.-opens-dialog > .umb-action-link').click(); + cy.get('[data-element="action-create-' + pickerDocTypeAlias + '"] > .umb-action-link').click(); + // Fill out content + cy.umbracoEditorHeaderName('ContentPickerContent'); + cy.get('.umb-node-preview-add').click(); + // Should really try and find a better way to do this, but umbracoTreeItem tries to click the content pane in the background + cy.get('[ng-if="vm.treeReady"] > .umb-tree > [ng-if="!tree.root.containsGroups"] > .umb-animated > .umb-tree-item__inner').click(); + // We have to wait for the picked content to show up or it wont be added. + cy.get('.umb-node-preview__description').should('be.visible'); + //save and publish + cy.umbracoButtonByLabelKey('buttons_saveAndPublish').click(); + cy.umbracoSuccessNotification().should('be.visible'); + + // Assert + cy.log('Checking that content is rendered correctly.') + const expectedContent = '

Acceptance test

' + cy.umbracoVerifyRenderedViewContent('contentpickercontent', expectedContent, true).should('be.true'); + // clean + cy.umbracoEnsureDocumentTypeNameNotExists(pickerDocTypeName); + cy.umbracoEnsureTemplateNameNotExists(pickerDocTypeName); + cy.umbracoEnsureDocumentTypeNameNotExists(pickedDocTypeName); + }); }); diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js index aeb4576ccd..4d468a0cf0 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js @@ -8,7 +8,8 @@ context('Members', () => { it('Create member', () => { const name = "Alice Bobson"; const email = "alice-bobson@acceptancetest.umbraco"; - const password = "$AUlkoF*St0kgPiyyVEk5iU5JWdN*F7&@OSl5Y4pOofnidfifkBj5Ns2ONv%FzsTl36V1E924Gw97zcuSeT7UwK&qb5l&O9h!d!w"; + const password = "$AUlkoF*St0kgPiyyVEk5iU5JWdN*F7&"; + const passwordTimeout = 20000 cy.umbracoEnsureMemberEmailNotExists(email); cy.umbracoSection('member'); @@ -24,8 +25,8 @@ context('Members', () => { cy.get('input#_umb_login').clear().type(email); cy.get('input#_umb_email').clear().type(email); - cy.get('input#password').clear().type(password); - cy.get('input#confirmPassword').clear().type(password); + cy.get('input#password').clear().type(password, { timeout: passwordTimeout }); + cy.get('input#confirmPassword').clear().type(password, { timeout: passwordTimeout }); // Save cy.get('.btn-success').click(); diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts index cce8a45da6..430f8aa108 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts @@ -1,18 +1,24 @@ /// +import { ScriptBuilder } from "umbraco-cypress-testhelpers"; + context('Scripts', () => { beforeEach(() => { cy.umbracoLogin(Cypress.env('username'), Cypress.env('password')); }); + function navigateToSettings() { + cy.umbracoSection('settings'); + cy.get('li .umb-tree-root:contains("Settings")').should("be.visible"); + } + it('Create new JavaScript file', () => { const name = "TestScript"; const fileName = name + ".js"; - cy.umbracoEnsureScriptNameNotExists(fileName); + cy.umbracoEnsureScriptNameNotExists(fileName); - cy.umbracoSection('settings'); - cy.get('li .umb-tree-root:contains("Settings")').should("be.visible"); + navigateToSettings() cy.umbracoTreeItem("settings", ["Scripts"]).rightclick(); @@ -27,9 +33,89 @@ context('Scripts', () => { //Assert cy.umbracoSuccessNotification().should('be.visible'); + cy.umbracoScriptExists(fileName).should('be.true'); + //Clean up cy.umbracoEnsureScriptNameNotExists(fileName); - }); + }); + it('Delete a JavaScript file', () => { + const name = "TestDeleteScriptFile"; + const fileName = name + ".js"; + + cy.umbracoEnsureScriptNameNotExists(fileName); + + const script = new ScriptBuilder() + .withName(name) + .withContent('alert("this is content");') + .build(); + + cy.saveScript(script); + + navigateToSettings() + + cy.umbracoTreeItem("settings", ["Scripts", fileName]).rightclick(); + cy.umbracoContextMenuAction("action-delete").click(); + cy.umbracoButtonByLabelKey("general_ok").click(); + + cy.contains(fileName).should('not.exist'); + cy.umbracoScriptExists(name).should('be.false'); + + cy.umbracoEnsureScriptNameNotExists(fileName); + }); + + it('Update JavaScript file', () => { + const name = "TestEditJavaScriptFile"; + const nameEdit = "Edited"; + let fileName = name + ".js"; + + const originalContent = 'console.log("A script);\n'; + const edit = 'alert("content");'; + const expected = originalContent + edit; + + cy.umbracoEnsureScriptNameNotExists(fileName); + + const script = new ScriptBuilder() + .withName(name) + .withContent(originalContent) + .build(); + cy.saveScript(script); + + navigateToSettings(); + cy.umbracoTreeItem("settings", ["Scripts", fileName]).click(); + + cy.get('.ace_text-input').type(edit, { force: true }); + + // Since scripts has no alias it should be safe to not use umbracoEditorHeaderName + // umbracoEditorHeaderName does not like {backspace} + cy.get('#headerName').type("{backspace}{backspace}{backspace}" + nameEdit).should('have.value', name+nameEdit); + fileName = name + nameEdit + ".js"; + cy.get('.btn-success').click(); + + cy.umbracoSuccessNotification().should('be.visible'); + cy.umbracoVerifyScriptContent(fileName, expected).should('be.true'); + + cy.umbracoEnsureScriptNameNotExists(fileName); + }); + + it('Can Delete folder', () => { + const folderName = "TestFolder"; + + // The way scripts and folders are fetched and deleted are identical + cy.umbracoEnsureScriptNameNotExists(folderName); + cy.saveFolder('scripts', folderName); + + navigateToSettings() + + cy.umbracoTreeItem("settings", ["Scripts", folderName]).rightclick(); + cy.umbracoContextMenuAction("action-delete").click(); + cy.umbracoButtonByLabelKey("general_ok").click(); + + cy.contains(folderName).should('not.exist'); + cy.umbracoScriptExists(folderName).should('be.false') + + // A script an a folder is the same thing in this case + cy.umbracoEnsureScriptNameNotExists(folderName); + }); }); diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts index aff1c38093..da1adedaeb 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts @@ -18,25 +18,30 @@ context('Templates', () => { cy.umbracoContextMenuAction("action-create").click(); } - - it('Create template', () => { - const name = "Test template test"; + const name = "Create template test"; cy.umbracoEnsureTemplateNameNotExists(name); createTemplate(); //Type name cy.umbracoEditorHeaderName(name); - /* Make an edit, if you don't the file will be create twice, - only happens in testing though, probably because the test is too fast - Certifiably mega wonk regardless */ - cy.get('.ace_text-input').type("var num = 5;", {force:true} ); - - //Save + // Save + // We must drop focus for the auto save event to occur. + cy.get('.btn-success').focus(); + // And then wait for the auto save event to finish by finding the page in the tree view. + // This is a bit of a roundabout way to find items in a treev view since we dont use umbracoTreeItem + // but we must be able to wait for the save evnent to finish, and we can't do that with umbracoTreeItem + cy.get('[data-element="tree-item-templates"] > :nth-child(2) > .umb-animated > .umb-tree-item__inner > .umb-tree-item__label') + .contains(name).should('be.visible', { timeout: 10000 }); + // Now that the auto save event has finished we can save + // and there wont be any duplicates or file in use errors. cy.get('.btn-success').click(); //Assert cy.umbracoSuccessNotification().should('be.visible'); + // For some reason cy.umbracoErrorNotification tries to click the element which is not possible + // if it doesn't actually exist, making should('not.be.visible') impossible. + cy.get('.umb-notifications__notifications > .alert-error').should('not.be.visible'); //Clean up cy.umbracoEnsureTemplateNameNotExists(name); diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Tour/backofficeTour.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Tour/backofficeTour.ts index 307da2518c..9bc1fff488 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Tour/backofficeTour.ts +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Tour/backofficeTour.ts @@ -71,7 +71,7 @@ function resetTourData() { function runBackOfficeIntroTour(percentageComplete, buttonText, timeout) { cy.get('[data-element="help-tours"]').should("be.visible"); cy.get('[data-element="help-tours"]').click(); - cy.get('[data-element="help-tours"] .umb-progress-circle', { timeout: timeout }).get('[percentage]').contains(percentageComplete + '%'); + cy.get('[data-element="help-tours"] .umb-progress-circle', { timeout: timeout }).get('[percentage]').contains(percentageComplete + '%', { timeout: timeout }); cy.get('[data-element="help-tours"]').click(); cy.get('[data-element="tour-umbIntroIntroduction"] .umb-button').should("be.visible"); cy.get('[data-element="tour-umbIntroIntroduction"] .umb-button').contains(buttonText); diff --git a/src/Umbraco.Tests.AcceptanceTest/package.json b/src/Umbraco.Tests.AcceptanceTest/package.json index e845681f18..996a0cd2f8 100644 --- a/src/Umbraco.Tests.AcceptanceTest/package.json +++ b/src/Umbraco.Tests.AcceptanceTest/package.json @@ -9,7 +9,7 @@ "cross-env": "^7.0.2", "cypress": "^5.1.0", "ncp": "^2.0.0", - "umbraco-cypress-testhelpers": "^1.0.0-beta-50", + "umbraco-cypress-testhelpers": "^1.0.0-beta-51", "prompt": "^1.0.0" }, "dependencies": { diff --git a/src/Umbraco.Tests/Services/ContentServiceTests.cs b/src/Umbraco.Tests/Services/ContentServiceTests.cs index 041dabe7d2..bfbb9a0d95 100644 --- a/src/Umbraco.Tests/Services/ContentServiceTests.cs +++ b/src/Umbraco.Tests/Services/ContentServiceTests.cs @@ -1537,6 +1537,53 @@ namespace Umbraco.Tests.Services Assert.That(content.HasIdentity, Is.True); } + [Test] + public void Can_Update_Content_Property_Values() + { + IContentType contentType = MockedContentTypes.CreateSimpleContentType(); + ServiceContext.ContentTypeService.Save(contentType); + IContent content = MockedContent.CreateSimpleContent(contentType, "hello"); + content.SetValue("title", "title of mine"); + content.SetValue("bodyText", "hello world"); + ServiceContext.ContentService.SaveAndPublish(content); + + // re-get + content = ServiceContext.ContentService.GetById(content.Id); + content.SetValue("title", "another title of mine"); // Change a value + content.SetValue("bodyText", null); // Clear a value + content.SetValue("author", "new author"); // Add a value + ServiceContext.ContentService.SaveAndPublish(content); + + // re-get + content = ServiceContext.ContentService.GetById(content.Id); + Assert.AreEqual("another title of mine", content.GetValue("title")); + Assert.IsNull(content.GetValue("bodyText")); + Assert.AreEqual("new author", content.GetValue("author")); + + content.SetValue("title", "new title"); + content.SetValue("bodyText", "new body text"); + content.SetValue("author", "new author text"); + ServiceContext.ContentService.Save(content); // new non-published version + + // re-get + content = ServiceContext.ContentService.GetById(content.Id); + content.SetValue("title", null); // Clear a value + content.SetValue("bodyText", null); // Clear a value + ServiceContext.ContentService.Save(content); // saving non-published version + + // re-get + content = ServiceContext.ContentService.GetById(content.Id); + Assert.IsNull(content.GetValue("title")); // Test clearing the value worked with the non-published version + Assert.IsNull(content.GetValue("bodyText")); + Assert.AreEqual("new author text", content.GetValue("author")); + + // make sure that the published version remained the same + var publishedContent = ServiceContext.ContentService.GetVersion(content.PublishedVersionId); + Assert.AreEqual("another title of mine", publishedContent.GetValue("title")); + Assert.IsNull(publishedContent.GetValue("bodyText")); + Assert.AreEqual("new author", publishedContent.GetValue("author")); + } + [Test] public void Can_Bulk_Save_Content() { diff --git a/src/Umbraco.Tests/Services/MediaServiceTests.cs b/src/Umbraco.Tests/Services/MediaServiceTests.cs index b3dc274c5e..52f26ecb4d 100644 --- a/src/Umbraco.Tests/Services/MediaServiceTests.cs +++ b/src/Umbraco.Tests/Services/MediaServiceTests.cs @@ -26,6 +26,30 @@ namespace Umbraco.Tests.Services [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true)] public class MediaServiceTests : TestWithSomeContentBase { + [Test] + public void Can_Update_Media_Property_Values() + { + IMediaType mediaType = MockedContentTypes.CreateSimpleMediaType("test", "Test"); + ServiceContext.MediaTypeService.Save(mediaType); + IMedia media = MockedMedia.CreateSimpleMedia(mediaType, "hello", -1); + media.SetValue("title", "title of mine"); + media.SetValue("bodyText", "hello world"); + ServiceContext.MediaService.Save(media); + + // re-get + media = ServiceContext.MediaService.GetById(media.Id); + media.SetValue("title", "another title of mine"); // Change a value + media.SetValue("bodyText", null); // Clear a value + media.SetValue("author", "new author"); // Add a value + ServiceContext.MediaService.Save(media); + + // re-get + media = ServiceContext.MediaService.GetById(media.Id); + Assert.AreEqual("another title of mine", media.GetValue("title")); + Assert.IsNull(media.GetValue("bodyText")); + Assert.AreEqual("new author", media.GetValue("author")); + } + /// /// Used to list out all ambiguous events that will require dispatching with a name /// diff --git a/src/Umbraco.Tests/Services/MemberServiceTests.cs b/src/Umbraco.Tests/Services/MemberServiceTests.cs index 816bc6d7dd..61bc3cf1c1 100644 --- a/src/Umbraco.Tests/Services/MemberServiceTests.cs +++ b/src/Umbraco.Tests/Services/MemberServiceTests.cs @@ -49,22 +49,27 @@ namespace Umbraco.Tests.Services } [Test] - public void Can_Update_Member_Property_Value() + public void Can_Update_Member_Property_Values() { IMemberType memberType = MockedContentTypes.CreateSimpleMemberType(); ServiceContext.MemberTypeService.Save(memberType); IMember member = MockedMember.CreateSimpleMember(memberType, "hello", "helloworld@test123.com", "hello", "hello"); member.SetValue("title", "title of mine"); + member.SetValue("bodyText", "hello world"); ServiceContext.MemberService.Save(member); // re-get member = ServiceContext.MemberService.GetById(member.Id); - member.SetValue("title", "another title of mine"); + member.SetValue("title", "another title of mine"); // Change a value + member.SetValue("bodyText", null); // Clear a value + member.SetValue("author", "new author"); // Add a value ServiceContext.MemberService.Save(member); // re-get member = ServiceContext.MemberService.GetById(member.Id); Assert.AreEqual("another title of mine", member.GetValue("title")); + Assert.IsNull(member.GetValue("bodyText")); + Assert.AreEqual("new author", member.GetValue("author")); } [Test] diff --git a/src/Umbraco.Web.UI.Client/gulp/config.js b/src/Umbraco.Web.UI.Client/gulp/config.js index 3c32832ba0..9eb335a62c 100755 --- a/src/Umbraco.Web.UI.Client/gulp/config.js +++ b/src/Umbraco.Web.UI.Client/gulp/config.js @@ -30,6 +30,7 @@ module.exports = { // js files for backoffice // processed in the js task js: { + websitepreview: { files: "./src/websitepreview/**/*.js", out: "umbraco.websitepreview.js" }, preview: { files: "./src/preview/**/*.js", out: "umbraco.preview.js" }, installer: { files: "./src/installer/**/*.js", out: "umbraco.installer.js" }, filters: { files: "./src/common/filters/**/*.js", out: "umbraco.filters.js" }, @@ -56,7 +57,7 @@ module.exports = { ], out: "umbraco.directives.js" } - + }, //selectors for copying all views into the build diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js index ed20203d46..dad7932de4 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js @@ -164,7 +164,7 @@ function inviteSavePassword() { - if (formHelper.submitForm({ scope: $scope })) { + if (formHelper.submitForm({ scope: $scope, formCtrl: vm.inviteUserPasswordForm })) { vm.invitedUserPasswordModel.buttonState = "busy"; @@ -172,7 +172,7 @@ .then(function (data) { //success - formHelper.resetForm({ scope: $scope }); + formHelper.resetForm({ scope: $scope, formCtrl: vm.inviteUserPasswordForm }); vm.invitedUserPasswordModel.buttonState = "success"; //set the user and set them as logged in vm.invitedUser = data; @@ -181,7 +181,7 @@ vm.inviteStep = 2; }, function (err) { - formHelper.resetForm({ scope: $scope, hasErrors: true }); + formHelper.resetForm({ scope: $scope, hasErrors: true, formCtrl: vm.inviteUserPasswordForm }); formHelper.handleError(err); vm.invitedUserPasswordModel.buttonState = "error"; }); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js index 12e1144acc..122d430165 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js @@ -57,14 +57,32 @@ } } } - function replaceElementTypeBlockListUDIsResolver(obj, propClearingMethod) { - replaceRawBlockListUDIsResolver(obj.value, propClearingMethod); + function removeBlockReferences(obj) { + for (var k in obj) { + if(k === "contentUdi") { + delete obj[k]; + } else if(k === "settingsUdi") { + delete obj[k]; + } else { + // lets crawl through all properties of layout to make sure get captured all `contentUdi` and `settingsUdi` properties. + var propType = typeof obj[k]; + if(propType != null && (propType === "object" || propType === "array")) { + removeBlockReferences(obj[k]) + } + } + } } - clipboardService.registerPastePropertyResolver(replaceElementTypeBlockListUDIsResolver, clipboardService.TYPES.ELEMENT_TYPE); + + function elementTypeBlockResolver(obj, propPasteResolverMethod) { + // we could filter for specific Property Editor Aliases, but as the Block Editor structure can be used by many Property Editor we do not in this code know a good way to detect that this is a Block Editor and will therefor leave it to the value structure to determin this. + rawBlockResolver(obj.value, propPasteResolverMethod); + } + + clipboardService.registerPastePropertyResolver(elementTypeBlockResolver, clipboardService.TYPES.ELEMENT_TYPE); - function replaceRawBlockListUDIsResolver(value, propClearingMethod) { + function rawBlockResolver(value, propPasteResolverMethod) { if (value != null && typeof value === "object") { // we got an object, and it has these three props then we are most likely dealing with a Block Editor. @@ -72,19 +90,19 @@ replaceUdisOfObject(value.layout, value); - // replace UDIs for inner properties of this Block Editors content data. + // run resolvers for inner properties of this Blocks content data. if(value.contentData.length > 0) { value.contentData.forEach((item) => { for (var k in item) { - propClearingMethod(item[k], clipboardService.TYPES.RAW); + propPasteResolverMethod(item[k], clipboardService.TYPES.RAW); } }); } - // replace UDIs for inner properties of this Block Editors settings data. + // run resolvers for inner properties of this Blocks settings data. if(value.settingsData.length > 0) { value.settingsData.forEach((item) => { for (var k in item) { - propClearingMethod(item[k], clipboardService.TYPES.RAW); + propPasteResolverMethod(item[k], clipboardService.TYPES.RAW); } }); } @@ -93,7 +111,29 @@ } } - clipboardService.registerPastePropertyResolver(replaceRawBlockListUDIsResolver, clipboardService.TYPES.RAW); + clipboardService.registerPastePropertyResolver(rawBlockResolver, clipboardService.TYPES.RAW); + + + function provideNewUdisForBlockResolver(block, propPasteResolverMethod) { + + if(block.layout) { + // We do not support layout child blocks currently, these should be stripped out as we only will be copying a single entry. + removeBlockReferences(block.layout); + } + + if(block.data) { + // Make new UDI for content-element + block.data.udi = block.layout.contentUdi = udiService.create("element"); + } + + if(block.settingsData) { + // Make new UDI for settings-element + block.settingsData.udi = block.layout.settingsUdi = udiService.create("element"); + } + + } + + clipboardService.registerPastePropertyResolver(provideNewUdisForBlockResolver, clipboardService.TYPES.BLOCK); }]); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 77ed357c35..8a2b230a27 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -13,7 +13,7 @@ (function () { 'use strict'; - function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper, clipboardService) { + function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper, clipboardService, notificationsService) { /** * Simple mapping from property model content entry to editing model, @@ -773,6 +773,57 @@ return layoutEntry; + }, + /** + * @ngdoc method + * @name createFromBlockData + * @methodOf umbraco.services.blockEditorModelObject + * @description Insert data from raw models + * @return {Object | null} Layout entry object, to be inserted at a decired location in the layout object. Or ´null´ if the given ElementType isnt supported by the block configuration. + */ + createFromBlockData: function (blockData) { + + blockData = clipboardService.parseContentForPaste(blockData, clipboardService.TYPES.BLOCK); + + // As the blockData is a cloned object we can use its layout part for our layout entry. + var layoutEntry = blockData.layout; + if (layoutEntry === null) { + return null; + } + + var blockConfiguration; + + if (blockData.data) { + // Ensure that we support the alias: + blockConfiguration = this.getBlockConfiguration(blockData.data.contentTypeKey); + if(blockConfiguration === null) { + return null; + } + + this.value.contentData.push(blockData.data); + } else { + // We do not have data, this cannot be succesful paste. + return null; + } + + if (blockData.settingsData) { + // Ensure that we support the alias: + if(blockConfiguration.settingsElementTypeKey) { + // If we have settings for this Block Configuration, we need to check that they align, if we dont we do not want to fail. + if(blockConfiguration.settingsElementTypeKey === blockData.settingsData.contentTypeKey) { + this.value.settingsData.push(blockData.settingsData); + } else { + notificationsService.error("Clipboard", "Couldn't paste because settings-data is not compatible."); + return null; + } + } else { + // We do not have settings currently, so lets get rid of the settings part and move on with the paste. + delete layoutEntry.settingUdi; + } + } + + return layoutEntry; + }, /** diff --git a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js index 58ed07367e..83fd3d08c2 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js @@ -10,33 +10,67 @@ * The service has a set way for defining a data-set by a entryType and alias, which later will be used to retrive the posible entries for a paste scenario. * */ -function clipboardService(notificationsService, eventsService, localStorageService, iconHelper) { +function clipboardService($window, notificationsService, eventsService, localStorageService, iconHelper) { const TYPES = {}; TYPES.ELEMENT_TYPE = "elementType"; + TYPES.BLOCK = "block"; TYPES.RAW = "raw"; var clearPropertyResolvers = {}; var pastePropertyResolvers = {}; var clipboardTypeResolvers = {}; - clipboardTypeResolvers[TYPES.ELEMENT_TYPE] = function(data, propMethod) { - for (var t = 0; t < data.variants[0].tabs.length; t++) { - var tab = data.variants[0].tabs[t]; + clipboardTypeResolvers[TYPES.ELEMENT_TYPE] = function(element, propMethod) { + for (var t = 0; t < element.variants[0].tabs.length; t++) { + var tab = element.variants[0].tabs[t]; for (var p = 0; p < tab.properties.length; p++) { var prop = tab.properties[p]; propMethod(prop, TYPES.ELEMENT_TYPE); } } } + clipboardTypeResolvers[TYPES.BLOCK] = function (block, propMethod) { + + propMethod(block, TYPES.BLOCK); + + if(block.data) { + Object.keys(block.data).forEach( key => { + if(key === 'udi' || key === 'contentTypeKey') { + return; + } + propMethod(block.data[key], TYPES.RAW); + }); + } + + if(block.settingsData) { + Object.keys(block.settingsData).forEach( key => { + if(key === 'udi' || key === 'contentTypeKey') { + return; + } + propMethod(block.settingsData[key], TYPES.RAW); + }); + } + + /* + // Concept for supporting Block that contains other Blocks. + // Missing clarifications: + // How do we ensure that the inner blocks of a block is supported in the new scenario. Not that likely but still relevant, so considerations should be made. + if(block.references) { + // A Block clipboard entry can contain other Block Clipboard Entries, here we will make sure to resolve those identical to the main entry. + for (var r = 0; r < block.references.length; r++) { + clipboardTypeResolvers[TYPES.BLOCK](block.references[r], propMethod); + } + } + */ + } clipboardTypeResolvers[TYPES.RAW] = function(data, propMethod) { for (var p = 0; p < data.length; p++) { propMethod(data[p], TYPES.RAW); } } - var STORAGE_KEY = "umbClipboardService"; var retriveStorage = function() { @@ -64,7 +98,10 @@ function clipboardService(notificationsService, eventsService, localStorageServi var storageString = JSON.stringify(storage); try { + // Check that we can parse the JSON: var storageJSON = JSON.parse(storageString); + + // Store the string: localStorageService.set(STORAGE_KEY, storageString); eventsService.emit("clipboardService.storageUpdate"); @@ -82,11 +119,11 @@ function clipboardService(notificationsService, eventsService, localStorageServi type = type || "raw"; var resolvers = clearPropertyResolvers[type]; - - for (var i=0; i x.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin === true); + return _.some(externalLoginInfo.providers, x => x.properties && x.properties.UmbracoBackOfficeExternalLoginOptions && (x.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin === true)); } else { - return provider.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin; + return provider && provider.properties && provider.properties.UmbracoBackOfficeExternalLoginOptions && (provider.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin === true); } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less index 9567840049..a96c59de84 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less @@ -101,6 +101,7 @@ } + &__check { display: flex; position: relative; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less index 7962981592..5f0a6a3c00 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less @@ -19,7 +19,7 @@ yeah so this is a pain, but we must be super specific in targeting the mandatory property labels, otherwise all properties within a reqired, nested, nested content property will all appear mandatory */ - > ng-form > .control-group > .umb-el-wrap > .control-header label:after { + .umb-property > ng-form > .control-group > .umb-el-wrap > .control-header label:after { content: '*'; color: @red; } diff --git a/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js b/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js index 0a73fadedb..78f753a8ca 100644 --- a/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js @@ -5,13 +5,13 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.services']) - .controller("previewController", function ($scope, $window, $location) { + .controller("previewController", function ($scope, $window, $location, $http) { $scope.currentCulture = { iso: '', title: '...', icon: 'icon-loading' } var cultures = []; $scope.tabbingActive = false; - // There are a number of ways to detect when a focus state should be shown when using the tab key and this seems to be the simplest solution. + // There are a number of ways to detect when a focus state should be shown when using the tab key and this seems to be the simplest solution. // For more information about this approach, see https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 function handleFirstTab(evt) { if (evt.keyCode === 9) { @@ -110,7 +110,7 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi if (isInit === "true") { //do not continue, this is the first load of this new window, if this is passed in it means it's been //initialized by the content editor and then the content editor will actually re-load this window without - //this flag. This is a required trick to get around chrome popup mgr. + //this flag. This is a required trick to get around chrome popup mgr. return; } @@ -121,7 +121,7 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.valueAreLoaded = false; $scope.devices = [ - { name: "fullsize", css: "fullsize", icon: "icon-application-window-alt", title: "Browser" }, + { name: "fullsize", css: "fullsize", icon: "icon-application-window-alt", title: "Fit browser" }, { name: "desktop", css: "desktop shadow", icon: "icon-display", title: "Desktop" }, { name: "laptop - 1366px", css: "laptop shadow", icon: "icon-laptop", title: "Laptop" }, { name: "iPad portrait - 768px", css: "iPad-portrait shadow", icon: "icon-ipad", title: "Tablet portrait" }, @@ -157,17 +157,33 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.windowClickHandler = function () { closeOthers(); } + function windowBlurHandler() { closeOthers(); $scope.$digest(); } + window.addEventListener("blur", windowBlurHandler); - var win = $($window); + function windowVisibilityHandler(e) { - win.on("blur", windowBlurHandler); + var amountOfPreviewSessions = localStorage.getItem('UmbPreviewSessionAmount'); + + // When tab is visible again: + if(document.hidden === false) { + checkPreviewState(); + } + } + document.addEventListener("visibilitychange", windowVisibilityHandler); + + function beforeUnloadHandler(e) { + endPreviewSession(); + } + window.addEventListener("beforeunload", beforeUnloadHandler, false); $scope.$on("$destroy", function () { - win.off("blur", handleBlwindowBlurHandlerur); + window.removeEventListener("blur", windowBlurHandler); + document.removeEventListener("visibilitychange", windowVisibilityHandler); + window.removeEventListener("beforeunload", beforeUnloadHandler); }); @@ -183,6 +199,197 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.pageUrl = "frame?" + query; } } + function getCookie(cname) { + var name = cname + "="; + var ca = document.cookie.split(";"); + for(var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == " ") { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return null; + } + function setCookie(cname, cvalue, exminutes) { + var d = new Date(); + d.setTime(d.getTime() + (exminutes * 60 * 1000)); + document.cookie = cname + "=" + cvalue + ";expires="+d.toUTCString() + ";path=/"; + } + var hasPreviewDialog = false; + function checkPreviewState() { + if (getCookie("UMB_PREVIEW") === null) { + + if(hasPreviewDialog === true) return; + hasPreviewDialog = true; + + // Ask to re-enter preview mode? + + const localizeVarsFallback = { + "returnToPreviewHeadline": "Preview content?", + "returnToPreviewDescription":"You have ended preview mode, do you want to continue previewing this content?", + "returnToPreviewButton":"Preview" + }; + const umbLocalizedVars = Object.assign(localizeVarsFallback, $window.umbLocalizedVars); + + + // This modal is also used in websitepreview.js + var modelStyles = ` + + /* Webfont: LatoLatin-Bold */ + @font-face { + font-family: 'Lato'; + src: url('https://fonts.googleapis.com/css2?family=Lato:wght@700&display=swap'); + font-style: normal; + font-weight: 700; + font-display: swap; + text-rendering: optimizeLegibility; + } + + .umbraco-preview-dialog { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + z-index: 99999999; + top:0; + bottom:0; + left:0; + right:0; + overflow: auto; + background-color: rgba(0,0,0,0.6); + } + + .umbraco-preview-dialog__modal { + background-color: #fff; + border-radius: 6px; + box-shadow: 0 3px 7px rgba(0,0,0,0.3); + margin: auto; + padding: 30px 40px; + width: 100%; + max-width: 540px; + font-family: Lato,Helvetica Neue,Helvetica,Arial,sans-serif; + font-size: 15px; + } + + .umbraco-preview-dialog__headline { + font-weight: 700; + font-size: 22px; + color: #1b264f; + margin-top:10px; + margin-bottom:20px; + } + .umbraco-preview-dialog__question { + margin-bottom:30px; + } + .umbraco-preview-dialog__modal > button { + display: inline-block; + cursor: pointer; + padding: 8px 18px; + text-align: center; + vertical-align: middle; + border-radius: 3px; + border:none; + font-family: inherit; + font-weight: 700; + font-size: 15px; + float:right; + margin-left:10px; + + color: #1b264f; + background-color: #f6f1ef; + } + .umbraco-preview-dialog__modal > button:hover { + color: #2152a3; + background-color: #f6f1ef; + } + .umbraco-preview-dialog__modal > button.umbraco-preview-dialog__continue { + color: #fff; + background-color: #2bc37c; + } + .umbraco-preview-dialog__modal > button.umbraco-preview-dialog__continue:hover { + background-color: #39d38b; + } + `; + + var bodyEl = document.getElementsByTagName("BODY")[0]; + + var fragment = document.createElement("div"); + var shadowRoot = fragment.attachShadow({ mode: 'open' }); + + var style = document.createElement("style"); + style.innerHTML = modelStyles; + shadowRoot.appendChild(style); + + var con = document.createElement("div"); + con.className = "umbraco-preview-dialog"; + shadowRoot.appendChild(con); + + var modal = document.createElement("div"); + modal.className = "umbraco-preview-dialog__modal"; + modal.innerHTML = `
${umbLocalizedVars.returnToPreviewHeadline}
+
${umbLocalizedVars.returnToPreviewDescription}
`; + con.appendChild(modal); + + var continueButton = document.createElement("button"); + continueButton.type = "button"; + continueButton.className = "umbraco-preview-dialog__continue"; + continueButton.innerHTML = umbLocalizedVars.returnToPreviewButton; + continueButton.addEventListener("click", () => { + bodyEl.removeChild(fragment); + reenterPreviewMode(); + hasPreviewDialog = false; + }); + modal.appendChild(continueButton); + + bodyEl.appendChild(fragment); + continueButton.focus(); + + } + } + function reenterPreviewMode() { + //Re-enter Preview Mode + $http({ + method: 'POST', + url: '../preview/enterPreview', + params: { + id: $scope.pageId + } + }) + startPreviewSession(); + } + function getPageURL() { + var culture = $location.search().culture || getParameterByName("culture"); + var relativeUrl = "/" + $scope.pageId; + if (culture) { + relativeUrl += '?culture=' + culture; + } + return relativeUrl; + } + + function startPreviewSession() { + // lets registrer this preview session. + var amountOfPreviewSessions = Math.max(localStorage.getItem('UmbPreviewSessionAmount') || 0, 0); + amountOfPreviewSessions++; + localStorage.setItem('UmbPreviewSessionAmount', amountOfPreviewSessions); + } + function resetPreviewSessions() { + localStorage.setItem('UmbPreviewSessionAmount', 0); + } + function endPreviewSession() { + var amountOfPreviewSessions = localStorage.getItem('UmbPreviewSessionAmount') || 0; + amountOfPreviewSessions--; + localStorage.setItem('UmbPreviewSessionAmount', amountOfPreviewSessions); + + if(amountOfPreviewSessions <= 0) { + // We are good to end preview mode. + navigator.sendBeacon("../preview/end"); + } + } + startPreviewSession(); + /*****************************************************************************/ /* Preview devices */ /*****************************************************************************/ @@ -192,21 +399,26 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.previewDevice = device; }; + /*****************************************************************************/ + /* Open website in preview mode */ + /*****************************************************************************/ + + $scope.openInBrowser = function () { + setCookie("UMB-WEBSITE-PREVIEW-ACCEPT", "true", 5); + window.open(getPageURL(), "_blank"); + }; + /*****************************************************************************/ /* Exit Preview */ /*****************************************************************************/ $scope.exitPreview = function () { - - var culture = $location.search().culture || getParameterByName("culture"); - var relativeUrl = "/" + $scope.pageId; - if (culture) { - relativeUrl += '?culture=' + culture; - } - window.top.location.href = "../preview/end?redir=" + encodeURIComponent(relativeUrl); + resetPreviewSessions(); + window.top.location.href = "../preview/end?redir=" + encodeURIComponent(getPageURL()); }; $scope.onFrameLoaded = function (iframe) { + $scope.frameLoaded = true; configureSignalR(iframe); @@ -260,8 +472,8 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi var vm = this; vm.$postLink = function () { - var resultFrame = $element.find("#resultFrame"); - resultFrame.on("load", iframeReady); + var resultFrame = $element.find("#resultFrame").get(0); + resultFrame.addEventListener("load", iframeReady); }; function iframeReady() { diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html index ba909c1c33..b84a72deac 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html @@ -10,7 +10,7 @@ - - ]]> - - - - throw - - - ashx,aspx,ascx,config,cshtml,vbhtml,asmx,air,axd,swf,xml,xhtml,html,htm,php,htaccess - - - assets/img/login.jpg - - - - - - false - - true - - false - - - - - - - - - - - - - - + + + + + + + + + + + + + 1 + + + + + + + + your@email.here + + + + + + Preview mode + + … + + + Click to end preview mode + + + + + ]]> + + + + throw + + + ashx,aspx,ascx,config,cshtml,vbhtml,asmx,air,axd,swf,xml,xhtml,html,htm,php,htaccess + + + assets/img/login.jpg + + + + + + false + + true + + false + + + + + + + + + + + + + + diff --git a/src/Umbraco.Web.UI/web.Template.config b/src/Umbraco.Web.UI/web.Template.config index a096cc6c32..03f462fb9e 100644 --- a/src/Umbraco.Web.UI/web.Template.config +++ b/src/Umbraco.Web.UI/web.Template.config @@ -72,7 +72,7 @@ you must configure your SMTP settings here - for example: --> diff --git a/src/Umbraco.Web/Editors/PreviewController.cs b/src/Umbraco.Web/Editors/PreviewController.cs index d8979e75b2..2423599aa7 100644 --- a/src/Umbraco.Web/Editors/PreviewController.cs +++ b/src/Umbraco.Web/Editors/PreviewController.cs @@ -101,11 +101,7 @@ namespace Umbraco.Web.Editors [UmbracoAuthorize] public ActionResult Frame(int id, string culture) { - var user = _umbracoContextAccessor.UmbracoContext.Security.CurrentUser; - - var previewToken = _publishedSnapshotService.EnterPreview(user, id); - - Response.Cookies.Set(new HttpCookie(Constants.Web.PreviewCookieName, previewToken)); + EnterPreview(id); // use a numeric URL because content may not be in cache and so .Url would fail var query = culture.IsNullOrWhiteSpace() ? string.Empty : $"?culture={culture}"; @@ -113,7 +109,16 @@ namespace Umbraco.Web.Editors return null; } + public ActionResult EnterPreview(int id) + { + var user = _umbracoContextAccessor.UmbracoContext.Security.CurrentUser; + var previewToken = _publishedSnapshotService.EnterPreview(user, id); + + Response.Cookies.Set(new HttpCookie(Constants.Web.PreviewCookieName, previewToken)); + + return null; + } public ActionResult End(string redir = null) { var previewToken = Request.GetPreviewCookieValue(); @@ -122,6 +127,9 @@ namespace Umbraco.Web.Editors System.Web.HttpContext.Current.ExpireCookie(Constants.Web.PreviewCookieName); + // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. + System.Web.HttpContext.Current.ExpireCookie(Constants.Web.AcceptPreviewCookieName); + if (Uri.IsWellFormedUriString(redir, UriKind.Relative) && redir.StartsWith("//") == false && Uri.TryCreate(redir, UriKind.Relative, out Uri url)) diff --git a/src/Umbraco.Web/Search/ExamineComponent.cs b/src/Umbraco.Web/Search/ExamineComponent.cs index ca16aaee65..c9d7b7cf56 100644 --- a/src/Umbraco.Web/Search/ExamineComponent.cs +++ b/src/Umbraco.Web/Search/ExamineComponent.cs @@ -269,6 +269,14 @@ namespace Umbraco.Web.Search DeleteIndexForEntity(c4.Id, false); } break; + case MessageType.RefreshByPayload: + var payload = (MemberCacheRefresher.JsonPayload[])args.MessageObject; + var members = payload.Select(x => _services.MemberService.GetById(x.Id)); + foreach(var m in members) + { + ReIndexForMember(m); + } + break; case MessageType.RefreshAll: case MessageType.RefreshByJson: default: @@ -746,6 +754,6 @@ namespace Umbraco.Web.Search } #endregion - + } } diff --git a/src/Umbraco.Web/Services/IconService.cs b/src/Umbraco.Web/Services/IconService.cs index ba6692c7b9..206af296f9 100644 --- a/src/Umbraco.Web/Services/IconService.cs +++ b/src/Umbraco.Web/Services/IconService.cs @@ -19,11 +19,13 @@ namespace Umbraco.Web.Services _globalSettings = globalSettings; } + /// public IList GetAllIcons() { var icons = new List(); - var iconNames = GetAllIconNames(); + var directory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/")); + var iconNames = directory.GetFiles("*.svg"); iconNames.OrderBy(f => f.Name).ToList().ForEach(iconInfo => { @@ -43,56 +45,36 @@ namespace Umbraco.Web.Services { return string.IsNullOrWhiteSpace(iconName) ? null - : CreateIconModel(iconName.StripFileExtension()); + : CreateIconModel(iconName.StripFileExtension(), IOHelper.MapPath($"{_globalSettings.IconsPath}/{iconName}.svg")); } /// /// Gets an IconModel using values from a FileInfo model /// /// - /// - private IconModel GetIcon(FileSystemInfo fileInfo) + /// + private IconModel GetIcon(FileInfo fileInfo) { return fileInfo == null || string.IsNullOrWhiteSpace(fileInfo.Name) ? null : CreateIconModel(fileInfo.Name.StripFileExtension(), fileInfo.FullName); } - /// - /// Gets an IconModel containing the icon name and SvgString - /// - /// - /// - private IconModel CreateIconModel(string iconName) - { - if (string.IsNullOrWhiteSpace(iconName)) - return null; - - var iconNames = GetAllIconNames(); - var iconPath = iconNames.FirstOrDefault(x => x.Name.InvariantEquals($"{iconName}.svg"))?.FullName; - return iconPath == null - ? null - : CreateIconModel(iconName, iconPath); - } - /// /// Gets an IconModel containing the icon name and SvgString /// /// /// - /// - private static IconModel CreateIconModel(string iconName, string iconPath) + /// + private IconModel CreateIconModel(string iconName, string iconPath) { - if (string.IsNullOrWhiteSpace(iconPath)) - return null; + var sanitizer = new HtmlSanitizer(); + sanitizer.AllowedAttributes.UnionWith(Constants.SvgSanitizer.Attributes); + sanitizer.AllowedCssProperties.UnionWith(Constants.SvgSanitizer.Attributes); + sanitizer.AllowedTags.UnionWith(Constants.SvgSanitizer.Tags); try { - var sanitizer = new HtmlSanitizer(); - sanitizer.AllowedAttributes.UnionWith(Constants.SvgSanitizer.Attributes); - sanitizer.AllowedCssProperties.UnionWith(Constants.SvgSanitizer.Attributes); - sanitizer.AllowedTags.UnionWith(Constants.SvgSanitizer.Tags); - var svgContent = System.IO.File.ReadAllText(iconPath); var sanitizedString = sanitizer.Sanitize(svgContent); @@ -109,26 +91,5 @@ namespace Umbraco.Web.Services return null; } } - - private IEnumerable GetAllIconNames() - { - // add icons from plugins - var appPlugins = new DirectoryInfo(IOHelper.MapPath(SystemDirectories.AppPlugins)); - var pluginIcons = appPlugins.Exists == false - ? new List() - : appPlugins.GetDirectories() - // Find all directories in App_Plugins that are named "Icons" and get a list of SVGs from them - .SelectMany(x => x.GetDirectories("Icons", SearchOption.AllDirectories)) - .SelectMany(x => x.GetFiles("*.svg", SearchOption.TopDirectoryOnly)); - - // add icons from IconsPath if not already added from plugins - var directory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/")); - var iconNames = directory.GetFiles("*.svg") - .Where(x => pluginIcons.Any(i => i.Name == x.Name) == false); - - iconNames = iconNames.Concat(pluginIcons).ToList(); - - return iconNames; - } } }