Merge pull request #7166 from umbraco/v8/feature/7133-do-not-paste-keys-in-nested-content

Cherry picked from SHA 49c438b55f

NC keys: Do not copy keys for Nested Content (Fix for #7133)
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js
This commit is contained in:
Warren Buckley
2020-05-29 15:18:46 +00:00
parent b90ed4c362
commit 1fe7de0b5f
6 changed files with 262 additions and 19 deletions

View File

@@ -12,6 +12,9 @@
*/
function clipboardService(notificationsService, eventsService, localStorageService, iconHelper) {
var clearPropertyResolvers = [];
var STORAGE_KEY = "umbClipboardService";
@@ -53,13 +56,32 @@ function clipboardService(notificationsService, eventsService, localStorageServi
return false;
}
var prepareEntryForStorage = function(entryData) {
var shallowCloneData = Object.assign({}, entryData);// Notice only a shallow copy, since we dont need to deep copy. (that will happen when storing the data)
delete shallowCloneData.key;
delete shallowCloneData.$$hashKey;
return shallowCloneData;
function clearPropertyForStorage(prop) {
for (var i=0; i<clearPropertyResolvers.length; i++) {
clearPropertyResolvers[i](prop, clearPropertyForStorage);
}
}
var prepareEntryForStorage = function(entryData, firstLevelClearupMethod) {
var cloneData = Utilities.copy(entryData);
if (firstLevelClearupMethod != undefined) {
firstLevelClearupMethod(cloneData);
}
// remove keys from sub-entries
for (var t = 0; t < cloneData.variants[0].tabs.length; t++) {
var tab = cloneData.variants[0].tabs[t];
for (var p = 0; p < tab.properties.length; p++) {
var prop = tab.properties[p];
clearPropertyForStorage(prop);
}
}
return cloneData;
}
var isEntryCompatible = function(entry, type, allowedAliases) {
@@ -75,6 +97,21 @@ function clipboardService(notificationsService, eventsService, localStorageServi
var service = {};
/**
* @ngdoc method
* @name umbraco.services.clipboardService#registrerPropertyClearingResolver
* @methodOf umbraco.services.clipboardService
*
* @param {string} function A method executed for every property and inner properties copied.
*
* @description
* Executed for all properties including inner properties when performing a copy action.
*/
service.registrerClearPropertyResolver = function(resolver) {
clearPropertyResolvers.push(resolver);
};
/**
* @ngdoc method
* @name umbraco.services.clipboardService#copy
@@ -84,15 +121,19 @@ function clipboardService(notificationsService, eventsService, localStorageServi
* @param {string} alias A string defining the alias of the data to store, example: 'product'
* @param {object} entry A object containing the properties to be saved, this could be the object of a ElementType, ContentNode, ...
* @param {string} displayLabel (optional) A string swetting the label to display when showing paste entries.
* @param {string} displayIcon (optional) A string setting the icon to display when showing paste entries.
* @param {string} uniqueKey (optional) A string prodiving an identifier for this entry, existing entries with this key will be removed to ensure that you only have the latest copy of this data.
*
* @description
* Saves a single JS-object with a type and alias to the clipboard.
*/
service.copy = function(type, alias, data, displayLabel) {
service.copy = function(type, alias, data, displayLabel, displayIcon, uniqueKey, firstLevelClearupMethod) {
var storage = retriveStorage();
var uniqueKey = data.key || data.$$hashKey || console.error("missing unique key for this content");
displayLabel = displayLabel || data.name;
displayIcon = displayIcon || iconHelper.convertFromLegacyIcon(data.icon);
uniqueKey = uniqueKey || data.key || console.error("missing unique key for this content");
// remove previous copies of this entry:
storage.entries = storage.entries.filter(
@@ -101,7 +142,7 @@ function clipboardService(notificationsService, eventsService, localStorageServi
}
);
var entry = {unique:uniqueKey, type:type, alias:alias, data:prepareEntryForStorage(data), label:displayLabel || data.name, icon:iconHelper.convertFromLegacyIcon(data.icon)};
var entry = {unique:uniqueKey, type:type, alias:alias, data:prepareEntryForStorage(data, firstLevelClearupMethod), label:displayLabel, icon:displayIcon};
storage.entries.push(entry);
if (saveStorage(storage) === true) {
@@ -124,16 +165,17 @@ function clipboardService(notificationsService, eventsService, localStorageServi
* @param {string} displayLabel A string setting the label to display when showing paste entries.
* @param {string} displayIcon A string setting the icon to display when showing paste entries.
* @param {string} uniqueKey A string prodiving an identifier for this entry, existing entries with this key will be removed to ensure that you only have the latest copy of this data.
* @param {string} firstLevelClearupMethod A string prodiving an identifier for this entry, existing entries with this key will be removed to ensure that you only have the latest copy of this data.
*
* @description
* Saves a single JS-object with a type and alias to the clipboard.
*/
service.copyArray = function(type, aliases, datas, displayLabel, displayIcon, uniqueKey) {
service.copyArray = function(type, aliases, datas, displayLabel, displayIcon, uniqueKey, firstLevelClearupMethod) {
var storage = retriveStorage();
// Clean up each entry
var copiedDatas = datas.map(data => prepareEntryForStorage(data));
var copiedDatas = datas.map(data => prepareEntryForStorage(data, firstLevelClearupMethod));
// remove previous copies of this entry:
storage.entries = storage.entries.filter(

View File

@@ -1,6 +1,58 @@
(function () {
'use strict';
/**
* When performing a copy, we do copy the ElementType Data Model, but each inner Nested Content property is still stored as the Nested Content Model, aka. each property is just storing its value. To handle this we need to ensure we handle both scenarios.
*/
angular.module('umbraco').run(['clipboardService', function (clipboardService) {
function clearNestedContentPropertiesForStorage(prop, propClearingMethod) {
// if prop.editor is "Umbraco.NestedContent"
if ((typeof prop === 'object' && prop.editor === "Umbraco.NestedContent")) {
var value = prop.value;
for (var i = 0; i < value.length; i++) {
var obj = value[i];
// remove the key
delete obj.key;
// Loop through all inner properties:
for (var k in obj) {
propClearingMethod(obj[k]);
}
}
}
}
clipboardService.registrerClearPropertyResolver(clearNestedContentPropertiesForStorage)
function clearInnerNestedContentPropertiesForStorage(prop, propClearingMethod) {
// if we got an array, and it has a entry with ncContentTypeAlias this meants that we are dealing with a NestedContent property inside a NestedContent property.
if ((Array.isArray(prop) && prop.length > 0 && prop[0].ncContentTypeAlias !== undefined)) {
for (var i = 0; i < prop.length; i++) {
var obj = prop[i];
// remove the key
delete obj.key;
// Loop through all inner properties:
for (var k in obj) {
propClearingMethod(obj[k]);
}
}
}
}
clipboardService.registrerClearPropertyResolver(clearInnerNestedContentPropertiesForStorage)
}]);
angular
.module('umbraco')
.component('nestedContentPropertyEditor', {
@@ -13,7 +65,7 @@
}
});
function NestedContentController($scope, $interpolate, $filter, $timeout, contentResource, localizationService, iconHelper, clipboardService, eventsService, overlayService, $routeParams, editorState) {
function NestedContentController($scope, $interpolate, $filter, $timeout, contentResource, localizationService, iconHelper, clipboardService, eventsService, overlayService) {
var vm = this;
var model = $scope.$parent.$parent.model;
@@ -76,7 +128,7 @@
}
localizationService.localize("clipboard_labelForArrayOfItemsFrom", [model.label, nodeName]).then(function(data) {
clipboardService.copyArray("elementTypeArray", aliases, vm.nodes, data, "icon-thumbnail-list", model.id);
clipboardService.copyArray("elementTypeArray", aliases, vm.nodes, data, "icon-thumbnail-list", model.id, clearNodeForCopy);
});
}
@@ -208,7 +260,8 @@
});
});
vm.overlayMenu.title = vm.overlayMenu.pasteItems.length > 0 ? labels.grid_addElement : labels.content_createEmpty;
vm.overlayMenu.title = labels.grid_addElement;
vm.overlayMenu.hideHeader = vm.overlayMenu.pasteItems.length > 0;
vm.overlayMenu.clickClearPaste = function ($event) {
$event.stopPropagation();
@@ -216,6 +269,7 @@
clipboardService.clearEntriesOfType("elementType", contentTypeAliases);
clipboardService.clearEntriesOfType("elementTypeArray", contentTypeAliases);
vm.overlayMenu.pasteItems = [];// This dialog is not connected via the clipboardService events, so we need to update manually.
vm.overlayMenu.hideHeader = false;
};
if (vm.overlayMenu.availableItems.length === 1 && vm.overlayMenu.pasteItems.length === 0) {
@@ -383,6 +437,11 @@
});
}
function clearNodeForCopy(clonedData) {
delete clonedData.key;
delete clonedData.$$hashKey;
}
vm.showCopy = clipboardService.isSupported();
vm.showPaste = false;
@@ -390,7 +449,7 @@
syncCurrentNode();
clipboardService.copy("elementType", node.contentTypeAlias, node);
clipboardService.copy("elementType", node.contentTypeAlias, node, null, null, null, clearNodeForCopy);
$event.stopPropagation();
}
@@ -488,10 +547,12 @@
}
// Enforce min items if we only have one scaffold type
var modelWasChanged = false;
if (vm.nodes.length < vm.minItems && vm.scaffolds.length === 1) {
for (var i = vm.nodes.length; i < model.config.minItems; i++) {
addNode(vm.scaffolds[0].contentTypeAlias);
}
modelWasChanged = true;
}
// If there is only one item, set it as current node
@@ -503,6 +564,9 @@
vm.inited = true;
if (modelWasChanged) {
updateModel();
}
updatePropertyActionStates();
checkAbilityToPasteContent();
}
@@ -585,8 +649,8 @@
}
function updatePropertyActionStates() {
copyAllEntriesAction.isDisabled = !model.value || model.value.length === 0;
removeAllEntriesAction.isDisabled = !model.value || model.value.length === 0;
copyAllEntriesAction.isDisabled = !model.value || !model.value.length;
removeAllEntriesAction.isDisabled = copyAllEntriesAction.isDisabled;
}

View File

@@ -13,8 +13,8 @@ var app = angular.module('umbraco', [
'ngSanitize',
//'ngMessages',
'tmh.dynamicLocale'
'tmh.dynamicLocale',
//'ngFileUpload',
//'LocalStorageModule',
'LocalStorageModule'
//'chart.js'
]);

View File

@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using Umbraco.Core;
using Umbraco.Core.Composing;
using Umbraco.Core.Events;
using Umbraco.Core.Models;
using Umbraco.Core.Services;
using Umbraco.Core.Services.Implement;
using Umbraco.Web.PropertyEditors;
namespace Umbraco.Web.Compose
{
public class NestedContentPropertyComponent : IComponent
{
public void Initialize()
{
ContentService.Copying += ContentService_Copying;
ContentService.Saving += ContentService_Saving;
}
private void ContentService_Copying(IContentService sender, CopyEventArgs<IContent> e)
{
// When a content node contains nested content property
// Check if the copied node contains a nested content
var nestedContentProps = e.Copy.Properties.Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.NestedContent);
UpdateNestedContentProperties(nestedContentProps, false);
}
private void ContentService_Saving(IContentService sender, ContentSavingEventArgs e)
{
// One or more content nodes could be saved in a bulk publish
foreach (var entity in e.SavedEntities)
{
// When a content node contains nested content property
// Check if the copied node contains a nested content
var nestedContentProps = entity.Properties.Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.NestedContent);
UpdateNestedContentProperties(nestedContentProps, true);
}
}
public void Terminate()
{
ContentService.Copying -= ContentService_Copying;
ContentService.Saving -= ContentService_Saving;
}
private void UpdateNestedContentProperties(IEnumerable<Property> nestedContentProps, bool onlyMissingKeys)
{
// Each NC Property on a doctype
foreach (var nestedContentProp in nestedContentProps)
{
// A NC Prop may have one or more values due to cultures
var propVals = nestedContentProp.Values;
foreach (var cultureVal in propVals)
{
// Remove keys from published value & any nested NC's
var updatedPublishedVal = CreateNestedContentKeys(cultureVal.PublishedValue?.ToString(), onlyMissingKeys);
cultureVal.PublishedValue = updatedPublishedVal;
// Remove keys from edited/draft value & any nested NC's
var updatedEditedVal = CreateNestedContentKeys(cultureVal.EditedValue?.ToString(), onlyMissingKeys);
cultureVal.EditedValue = updatedEditedVal;
}
}
}
// internal for tests
internal string CreateNestedContentKeys(string rawJson, bool onlyMissingKeys, Func<Guid> createGuid = null)
{
// used so we can test nicely
if (createGuid == null)
createGuid = () => Guid.NewGuid();
if (string.IsNullOrWhiteSpace(rawJson) || !rawJson.DetectIsJson())
return rawJson;
// Parse JSON
var complexEditorValue = JToken.Parse(rawJson);
UpdateNestedContentKeysRecursively(complexEditorValue, onlyMissingKeys, createGuid);
return complexEditorValue.ToString();
}
private void UpdateNestedContentKeysRecursively(JToken json, bool onlyMissingKeys, Func<Guid> createGuid)
{
// check if this is NC
var isNestedContent = json.SelectTokens($"$..['{NestedContentPropertyEditor.ContentTypeAliasPropertyKey}']", false).Any();
// select all values (flatten)
var allProperties = json.SelectTokens("$..*").OfType<JValue>().Select(x => x.Parent as JProperty).WhereNotNull().ToList();
foreach (var prop in allProperties)
{
if (prop.Name == NestedContentPropertyEditor.ContentTypeAliasPropertyKey)
{
// get it's sibling 'key' property
var ncKeyVal = prop.Parent["key"] as JValue;
// TODO: This bool seems odd, if the key is null, shouldn't we fill it in regardless of onlyMissingKeys?
if ((onlyMissingKeys && ncKeyVal == null) || (!onlyMissingKeys && ncKeyVal != null))
{
// create or replace
prop.Parent["key"] = createGuid().ToString();
}
}
else if (!isNestedContent || prop.Name != "key")
{
// this is an arbitrary property that could contain a nested complex editor
var propVal = prop.Value?.ToString();
// check if this might contain a nested NC
if (!propVal.IsNullOrWhiteSpace() && propVal.DetectIsJson() && propVal.InvariantContains(NestedContentPropertyEditor.ContentTypeAliasPropertyKey))
{
// recurse
var parsed = JToken.Parse(propVal);
UpdateNestedContentKeysRecursively(parsed, onlyMissingKeys, createGuid);
// set the value to the updated one
prop.Value = parsed.ToString();
}
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Core;
using Umbraco.Core.Composing;
namespace Umbraco.Web.Compose
{
[RuntimeLevel(MinLevel = RuntimeLevel.Run)]
public class NestedContentPropertyComposer : ComponentComposer<NestedContentPropertyComponent>, ICoreComposer
{ }
}

View File

@@ -129,6 +129,7 @@
<Compile Include="Cache\UserGroupCacheRefresher.cs" />
<Compile Include="Cache\UserGroupPermissionsCacheRefresher.cs" />
<Compile Include="Compose\BackOfficeUserAuditEventsComposer.cs" />
<Compile Include="Compose\NestedContentPropertyComponent.cs" />
<Compile Include="Compose\NotificationsComposer.cs" />
<Compile Include="Compose\PublicAccessComposer.cs" />
<Compile Include="Composing\CompositionExtensions\Installer.cs" />
@@ -233,6 +234,7 @@
<Compile Include="Mvc\SurfaceControllerTypeCollectionBuilder.cs" />
<Compile Include="Mvc\ValidateUmbracoFormRouteStringAttribute.cs" />
<Compile Include="Profiling\WebProfilingController.cs" />
<Compile Include="Compose\NestedContentPropertyComposer.cs" />
<Compile Include="PropertyEditors\ParameterEditors\MultipleMediaPickerParameterEditor.cs" />
<Compile Include="PropertyEditors\RichTextEditorPastedImages.cs" />
<Compile Include="PublishedCache\NuCache\PublishedSnapshotServiceOptions.cs" />