v10: make block editors supports stateful label expressions (#12909)

* mark ncNodeName as stateful allowing it to update the node name asynchronously and implement several checks for caching and fallthroughs

* ensure that the blocklist block component watches and updates stuff on the blockObject

* add $interpolate to the blockList Property Editor to interpolate the label with the saved state

* replace static label with the blockHtmlCompile directive to ensure labels are updated dynamically

* add failsafe in case block is not instantiated

* replace manual udi separation with the parse function from the udiParser service

* simplify watching, to avoid overwritting data object.

* virtual block label rendering

* destroy label scope

* add extra information for label doc

* revert previously used functions and add deprecation notices to them

* remove getBlockLabel, as it's not being used or publicly available.

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Niels Lyngsø
2022-08-26 15:09:34 +02:00
committed by GitHub
parent 6dab24265a
commit a105b3b770
5 changed files with 217 additions and 191 deletions

View File

@@ -3,67 +3,78 @@
// Cache for node names so we don't make a ton of requests
var ncNodeNameCache = {
id: "",
keys: {}
id: "",
keys: {}
};
angular.module("umbraco.filters").filter("ncNodeName", function (editorState, entityResource) {
angular.module("umbraco.filters").filter("ncNodeName", function (editorState, entityResource, udiParser) {
function formatLabel(firstNodeName, totalNodes) {
return totalNodes <= 1
? firstNodeName
// If there is more than one item selected, append the additional number of items selected to hint that
: firstNodeName + " (+" + (totalNodes - 1) + ")";
function formatLabel(firstNodeName, totalNodes) {
return totalNodes <= 1
? firstNodeName
// If there is more than one item selected, append the additional number of items selected to hint that
: firstNodeName + " (+" + (totalNodes - 1) + ")";
}
nodeNameFilter.$stateful = true;
function nodeNameFilter(input) {
// Check we have a value at all
if (typeof input === 'undefined' || input === "" || input.toString() === "0" || input === null) {
return "";
}
return function (input) {
var currentNode = editorState.getCurrent();
// Check we have a value at all
if (input === "" || input.toString() === "0") {
return "";
}
// Ensure a unique cache per editor instance
var key = "ncNodeName_" + currentNode.key;
if (ncNodeNameCache.id !== key) {
ncNodeNameCache.id = key;
ncNodeNameCache.keys = {};
}
var currentNode = editorState.getCurrent();
// MNTP values are comma separated IDs. We'll only fetch the first one for the NC header.
var ids = input.split(',');
var lookupId = ids[0];
var serviceInvoked = false;
// Ensure a unique cache per editor instance
var key = "ncNodeName_" + currentNode.key;
if (ncNodeNameCache.id !== key) {
ncNodeNameCache.id = key;
ncNodeNameCache.keys = {};
}
// See if there is a value in the cache and use that
if (ncNodeNameCache.keys[lookupId]) {
return formatLabel(ncNodeNameCache.keys[lookupId], ids.length);
}
// MNTP values are comma separated IDs. We'll only fetch the first one for the NC header.
var ids = input.split(',');
var lookupId = ids[0];
// No value, so go fetch one
// We'll put a temp value in the cache though so we don't
// make a load of requests while we wait for a response
ncNodeNameCache.keys[lookupId] = "Loading...";
// See if there is a value in the cache and use that
if (ncNodeNameCache.keys[lookupId]) {
return formatLabel(ncNodeNameCache.keys[lookupId], ids.length);
}
// If the service has already been invoked, don't do it again
if (serviceInvoked) {
return formatLabel(ncNodeNameCache.keys[lookupId], ids.length);
}
// No value, so go fetch one
// We'll put a temp value in the cache though so we don't
// make a load of requests while we wait for a response
ncNodeNameCache.keys[lookupId] = "Loading...";
serviceInvoked = true;
var type = lookupId.indexOf("umb://media/") === 0
? "Media"
: lookupId.indexOf("umb://member/") === 0
? "Member"
: "Document";
entityResource.getById(lookupId, type)
.then(
function (ent) {
ncNodeNameCache.keys[lookupId] = ent.name;
}
);
var udi = udiParser.parse(lookupId);
// Return the current value for now
return formatLabel(ncNodeNameCache.keys[lookupId], ids.length);
};
if (udi) {
entityResource.getById(udi.value, udi.entityType).then(function (ent) {
ncNodeNameCache.keys[lookupId] = ent.name;
}).catch(function () {
ncNodeNameCache.keys[lookupId] = "Error: Could not load";
});
} else {
ncNodeNameCache.keys[lookupId] = "Error: Not a UDI";
}
// Return the current value for now
return formatLabel(ncNodeNameCache.keys[lookupId], ids.length);
}
return nodeNameFilter;
}).filter("ncRichText", function () {
return function(input) {
return $("<div/>").html(input).text();
};
return function (input) {
return $("<div/>").html(input).text();
};
});

View File

@@ -13,7 +13,7 @@
(function () {
'use strict';
function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper, clipboardService, notificationsService) {
function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper, clipboardService, notificationsService, $compile) {
/**
* Simple mapping from property model content entry to editing model,
@@ -62,8 +62,8 @@
/**
* Map property values from an ElementModel to another ElementModel.
* Used to tricker watchers for synchronization.
* @param {Object} fromModel ElementModel to recive property values from.
* @param {Object} toModel ElementModel to recive property values from.
* @param {Object} fromModel ElementModel to receive property values from.
* @param {Object} toModel ElementModel to receive property values from.
*/
function mapElementValues(fromModel, toModel) {
if (!fromModel || !fromModel.variants) {
@@ -97,40 +97,6 @@
}
}
/**
* Generate label for Block, uses either the labelInterpolator or falls back to the contentTypeName.
* @param {Object} blockObject BlockObject to receive data values from.
*/
function getBlockLabel(blockObject) {
if (blockObject.labelInterpolator !== undefined) {
// blockobject.content may be null if the block is no longer allowed,
// so try and fall back to the label in the config,
// if that too is null, there's not much we can do, so just default to empty string.
var contentTypeName;
if(blockObject.content != null){
contentTypeName = blockObject.content.contentTypeName;
}
else if(blockObject.config != null && blockObject.config.label != null){
contentTypeName = blockObject.config.label;
}
else {
contentTypeName = "";
}
var labelVars = Object.assign({
"$contentTypeName": contentTypeName,
"$settings": blockObject.settingsData || {},
"$layout": blockObject.layout || {},
"$index": (blockObject.index || 0)+1
}, blockObject.data);
var label = blockObject.labelInterpolator(labelVars);
if (label) {
return label;
}
}
return blockObject.content.contentTypeName;
}
/**
* Used to add watchers on all properties in a content or settings model
*/
@@ -161,10 +127,6 @@
}
}
}
if (blockObject.__watchers.length === 0) {
// If no watcher where created, it means we have no properties to watch. This means that nothing will activate our generate the label, since its only triggered by watchers.
blockObject.updateLabel();
}
}
/**
@@ -176,8 +138,6 @@
// sync data:
prop.value = blockObject.data[prop.alias];
blockObject.updateLabel();
}
}
}
@@ -203,8 +163,6 @@
// sync data:
blockObject.data[prop.alias] = prop.value;
}
blockObject.updateLabel();
}
}
@@ -322,11 +280,11 @@
* @param {object} propertyModelValue data object of the property editor, usually model.value.
* @param {string} propertyEditorAlias alias of the property.
* @param {object} blockConfigurations block configurations.
* @param {angular-scope} scopeOfExistance A local angularJS scope that exists as long as the data exists.
* @param {angular-scope} scopeOfExistence A local angularJS scope that exists as long as the data exists.
* @param {angular-scope} propertyEditorScope A local angularJS scope that represents the property editors scope.
* @returns {BlockEditorModelObject} A instance of BlockEditorModelObject.
*/
function BlockEditorModelObject(propertyModelValue, propertyEditorAlias, blockConfigurations, scopeOfExistance, propertyEditorScope) {
function BlockEditorModelObject(propertyModelValue, propertyEditorAlias, blockConfigurations, scopeOfExistence, propertyEditorScope) {
if (!propertyModelValue) {
throw new Error("propertyModelValue cannot be undefined, to ensure we keep the binding to the angular model we need minimum an empty object.");
@@ -358,8 +316,8 @@
});
this.scaffolds = [];
this.isolatedScope = scopeOfExistance.$new(true);
this.__scopeOfExistence = scopeOfExistence;
this.isolatedScope = scopeOfExistence.$new(true);
this.isolatedScope.blockObjects = {};
this.__watchers.push(this.isolatedScope.$on("$destroy", this.destroy.bind(this)));
@@ -397,7 +355,7 @@
* @name getBlockConfiguration
* @methodOf umbraco.services.blockEditorModelObject
* @description Get block configuration object for a given contentElementTypeKey.
* @param {string} key contentElementTypeKey to recive the configuration model for.
* @param {string} key contentElementTypeKey to receive the configuration model for.
* @returns {Object | null} Configuration model for the that specific block. Or ´null´ if the contentElementTypeKey isnt available in the current block configurations.
*/
getBlockConfiguration: function (key) {
@@ -477,7 +435,7 @@
* @ngdoc method
* @name getAvailableBlocksForBlockPicker
* @methodOf umbraco.services.blockEditorModelObject
* @description Retrieve a list of available blocks, the list containing object with the confirugation model(blockConfigModel) and the element type model(elementTypeModel).
* @description Retrieve a list of available blocks, the list containing object with the configuration model(blockConfigModel) and the element type model(elementTypeModel).
* The purpose of this data is to provide it for the Block Picker.
* @return {Array} array of objects representing available blocks, each object containing properties blockConfigModel and elementTypeModel.
*/
@@ -503,7 +461,7 @@
* @name getScaffoldFromKey
* @methodOf umbraco.services.blockEditorModelObject
* @description Get scaffold model for a given contentTypeKey.
* @param {string} key contentTypeKey to recive the scaffold model for.
* @param {string} key contentTypeKey to receive the scaffold model for.
* @returns {Object | null} Scaffold model for the that content type. Or null if the scaffolding model dosnt exist in this context.
*/
getScaffoldFromKey: function (contentTypeKey) {
@@ -515,7 +473,7 @@
* @name getScaffoldFromAlias
* @methodOf umbraco.services.blockEditorModelObject
* @description Get scaffold model for a given contentTypeAlias, used by clipboardService.
* @param {string} alias contentTypeAlias to recive the scaffold model for.
* @param {string} alias contentTypeAlias to receive the scaffold model for.
* @returns {Object | null} Scaffold model for the that content type. Or null if the scaffolding model dosnt exist in this context.
*/
getScaffoldFromAlias: function (contentTypeAlias) {
@@ -535,8 +493,7 @@
* - content {Object}: Content model, the content data in a ElementType model.
* - settings {Object}: Settings model, the settings data in a ElementType model.
* - config {Object}: A local deep copy of the block configuration model.
* - label {string}: The label for this block.
* - updateLabel {Method}: Method to trigger an update of the label for this block.
* - label {string}: The compiled label for this block.
* - data {Object}: A reference to the content data object from your property editor model.
* - settingsData {Object}: A reference to the settings data object from your property editor model.
* - layout {Object}: A reference to the layout entry from your property editor model.
@@ -581,18 +538,12 @@
blockObject.key = String.CreateGuid().replace(/-/g, "");
blockObject.config = Utilities.copy(blockConfiguration);
if (blockObject.config.label && blockObject.config.label !== "") {
blockObject.labelInterpolator = $interpolate(blockObject.config.label);
/**
* @deprecated use blockObject.label instead
*/
blockObject.labelInterpolator = $interpolate(blockObject.config.label);
}
blockObject.__scope = this.isolatedScope;
blockObject.updateLabel = _.debounce(
function () {
// Check wether scope still exists, maybe object was destoyed in these seconds.
if (this.__scope) {
this.label = getBlockLabel(this);
this.__scope.$evalAsync();
}
}.bind(blockObject)
, 10);
// make basics from scaffold
if(contentScaffold !== null) {// We might not have contentScaffold
@@ -655,6 +606,7 @@
if (this.config.settingsElementTypeKey !== null) {
mapElementValues(settings, this.settings);
}
};
blockObject.sync = function () {
@@ -667,7 +619,61 @@
};
// first time instant update of label.
blockObject.label = getBlockLabel(blockObject);
blockObject.label = blockObject.content.contentTypeName;
blockObject.index = 0;
if (blockObject.config.label && blockObject.config.label !== "") {
var labelElement = $('<div></div>', { text: blockObject.config.label});
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
blockObject.label = mutation.target.textContent;
blockObject.__scope.$evalAsync();
});
});
observer.observe(labelElement[0], {characterData: true, subtree:true});
blockObject.__watchers.push(() => {
observer.disconnect();
})
blockObject.__labelScope = this.__scopeOfExistence.$new(true);
blockObject.__renderLabel = function() {
var labelVars = {
$contentTypeName: this.content.contentTypeName,
$settings: this.settingsData || {},
$layout: this.layout || {},
$index: this.index + 1,
... this.data
};
this.__labelScope = Object.assign(this.__labelScope, labelVars);
$compile(labelElement.contents())(this.__labelScope);
}.bind(blockObject)
} else {
blockObject.__renderLabel = function() {};
}
blockObject.updateLabel = _.debounce(blockObject.__renderLabel, 10);
// label rendering watchers:
blockObject.__watchers.push(blockObject.__scope.$watchCollection(function () {
return blockObject.data;
}, blockObject.__renderLabel));
blockObject.__watchers.push(blockObject.__scope.$watchCollection(function () {
return blockObject.settingsData;
}, blockObject.__renderLabel));
blockObject.__watchers.push(blockObject.__scope.$watchCollection(function () {
return blockObject.layout;
}, blockObject.__renderLabel));
blockObject.__watchers.push(blockObject.__scope.$watch(function () {
return blockObject.index;
}, blockObject.__renderLabel));
// Add blockObject to our isolated scope to enable watching its values:
this.isolatedScope.blockObjects["_" + blockObject.key] = blockObject;
@@ -679,9 +685,8 @@
this.__watchers.forEach(w => { w(); });
delete this.__watchers;
// help carbage collector:
// help garbage collector:
delete this.config;
delete this.layout;
delete this.data;
delete this.settingsData;
@@ -695,6 +700,11 @@
// destroyed. If we do that here it breaks the scope chain and validation.
delete this.__scope;
if(this.__labelScope) {
this.__labelScope.$destroy();
delete this.__labelScope;
}
// removes this method, making it impossible to destroy again.
delete this.destroy;
@@ -917,6 +927,7 @@
delete this.scaffolds;
this.isolatedScope.$destroy();
delete this.isolatedScope;
delete this.__scopeOfExistence;
delete this.destroy;
}
}

View File

@@ -1,8 +1,11 @@
<button type="button" class="btn-reset umb-outline blockelement-labelblock-editor blockelement__draggable-element"
ng-click="block.edit()"
ng-focus="block.focus"
ng-class="{ '--active': block.active, '--error': parentForm.$invalid && valFormManager.isShowingValidation() }"
val-server-property-class="">
<umb-icon icon="{{block.content.icon}}" class="icon"></umb-icon>
<span>{{block.label}}</span>
<button
type="button"
class="btn-reset umb-outline blockelement-labelblock-editor blockelement__draggable-element"
ng-click="block.edit()"
ng-focus="block.focus"
ng-class="{ '--active': block.active, '--error': parentForm.$invalid && valFormManager.isShowingValidation() }"
val-server-property-class=""
>
<umb-icon icon="{{block.content.icon}}" class="icon"></umb-icon>
<span>{{block.label}}</span>
</button>

View File

@@ -28,7 +28,7 @@
}
});
function BlockListController($scope, $timeout, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper, eventsService, $attrs) {
function BlockListController($scope, $timeout, $interpolate, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper, eventsService, $attrs) {
var unsubscribe = [];
var modelObject;

View File

@@ -1,85 +1,86 @@
(function () {
"use strict";
"use strict";
/**
* @ngdoc directive
* @name umbraco.directives.directive:umbBlockListBlock
* @description
* The component to render the view for a block.
* If a stylesheet is used then this uses a ShadowDom to make a scoped element.
* This way the backoffice styling does not collide with the block style.
*/
/**
* @ngdoc directive
* @name umbraco.directives.directive:umbBlockListBlock
* @description
* The component to render the view for a block.
* If a stylesheet is used then this uses a ShadowDom to make a scoped element.
* This way the backoffice styling does not collide with the block style.
*/
angular
.module("umbraco")
.component("umbBlockListBlock", {
controller: BlockListBlockController,
controllerAs: "model",
bindings: {
stylesheet: "@",
view: "@",
block: "=",
api: "<",
index: "<",
parentForm: "<"
},
require: {
valFormManager: "^^valFormManager"
}
}
);
angular
.module("umbraco")
.component("umbBlockListBlock", {
controller: BlockListBlockController,
controllerAs: "model",
bindings: {
stylesheet: "@",
view: "@",
block: "=",
api: "<",
index: "<",
parentForm: "<"
},
require: {
valFormManager: "^^valFormManager"
}
}
);
function BlockListBlockController($scope, $compile, $element) {
var model = this;
function BlockListBlockController($scope, $compile, $element) {
var model = this;
model.$onInit = function () {
// This is ugly and is only necessary because we are not using components and instead
// relying on ng-include. It is definitely possible to compile the contents
// of the view into the DOM using $templateCache and $http instead of using
// ng - include which means that the controllerAs flows directly to the view.
// This would mean that any custom components would need to be updated instead of relying on $scope.
// Guess we'll leave it for now but means all things need to be copied to the $scope and then all
// primitives need to be watched.
model.$onInit = function () {
// This is ugly and is only necessary because we are not using components and instead
// relying on ng-include. It is definitely possible to compile the contents
// of the view into the DOM using $templateCache and $http instead of using
// ng - include which means that the controllerAs flows directly to the view.
// This would mean that any custom components would need to be updated instead of relying on $scope.
// Guess we'll leave it for now but means all things need to be copied to the $scope and then all
// primitives need to be watched.
// let the Block know about its form
model.block.setParentForm(model.parentForm);
// let the Block know about its form
model.block.setParentForm(model.parentForm);
// let the Block know about the current index
model.block.index = model.index;
// let the Block know about the current index
model.block.index = model.index;
$scope.block = model.block;
$scope.api = model.api;
$scope.index = model.index;
$scope.parentForm = model.parentForm;
$scope.valFormManager = model.valFormManager;
$scope.block = model.block;
$scope.api = model.api;
$scope.index = model.index;
$scope.parentForm = model.parentForm;
$scope.valFormManager = model.valFormManager;
if (model.stylesheet) {
var shadowRoot = $element[0].attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
if (model.stylesheet) {
var shadowRoot = $element[0].attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
@import "${model.stylesheet}"
</style>
<div class="umb-block-list__block--view" ng-include="'${model.view}'"></div>
`;
$compile(shadowRoot)($scope);
}
else {
$element.append($compile('<div class="umb-block-list__block--view" ng-include="model.view"></div>')($scope));
}
};
$compile(shadowRoot)($scope);
}
else {
$element.append($compile('<div class="umb-block-list__block--view" ng-include="model.view"></div>')($scope));
}
};
// We need to watch for changes on primitive types and upate the $scope values.
model.$onChanges = function (changes) {
if (changes.index) {
var index = changes.index.currentValue;
$scope.index = index;
// We need to watch for changes on primitive types and update the $scope values.
model.$onChanges = function (changes) {
if (changes.index) {
var index = changes.index.currentValue;
$scope.index = index;
// let the Block know about the current index:
model.block.index = index;
model.block.updateLabel();
}
};
}
// let the Block know about the current index:
if ($scope.block) {
$scope.block.index = index;
}
}
};
}
})();