diff --git a/src/Umbraco.Core/Models/IContent.cs b/src/Umbraco.Core/Models/IContent.cs index 5910d01d34..d9bc32aaf0 100644 --- a/src/Umbraco.Core/Models/IContent.cs +++ b/src/Umbraco.Core/Models/IContent.cs @@ -172,10 +172,9 @@ namespace Umbraco.Core.Models /// /// A value indicating whether the culture can be published. /// - /// Fails if values cannot be published, e.g. if some values are not valid. + /// Fails if properties don't pass variant validtion rules. /// Publishing must be finalized via the content service SavePublishing method. /// - // fixme - should return an attempt with error results bool PublishCulture(string culture = "*"); /// diff --git a/src/Umbraco.Core/Models/IContentBase.cs b/src/Umbraco.Core/Models/IContentBase.cs index 2cf2d85024..de1b2666d5 100644 --- a/src/Umbraco.Core/Models/IContentBase.cs +++ b/src/Umbraco.Core/Models/IContentBase.cs @@ -139,7 +139,7 @@ namespace Umbraco.Core.Models // fixme validate published cultures? /// - /// Validates the content item's properties. + /// Validates the content item's properties pass variant rules /// /// If the content type is variant, then culture can be either '*' or an actual culture, but neither 'null' nor /// 'empty'. If the content type is invariant, then culture can be either '*' or null or empty. diff --git a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs index ded14c40ab..fe0e46941a 100644 --- a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs +++ b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs @@ -96,7 +96,11 @@ namespace Umbraco.Tests.TestHelpers.ControllerTesting var httpContext = Mock.Of( http => http.User == owinContext.Authentication.User //ensure the request exists with a cookies collection - && http.Request == Mock.Of(r => r.Cookies == new HttpCookieCollection()) + && http.Request == Mock.Of(r => r.Cookies == new HttpCookieCollection() + && r.RequestContext == new System.Web.Routing.RequestContext + { + RouteData = new System.Web.Routing.RouteData() + }) //ensure the request exists with an items collection && http.Items == httpContextItems); //chuck it into the props since this is what MS does when hosted and it's needed there diff --git a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestRunner.cs b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestRunner.cs index 64a22926a0..5834415568 100644 --- a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestRunner.cs +++ b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestRunner.cs @@ -58,14 +58,14 @@ namespace Umbraco.Tests.TestHelpers.ControllerTesting var response = await server.HttpClient.SendAsync(request); Console.WriteLine(response); - var json = ""; if (response.IsSuccessStatusCode == false) { WriteResponseError(response); } - else + + var json = (await ((StreamContent)response.Content).ReadAsStringAsync()).TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix); + if (!json.IsNullOrWhiteSpace()) { - json = (await ((StreamContent) response.Content).ReadAsStringAsync()).TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix); var deserialized = JsonConvert.DeserializeObject(json); Console.Write(JsonConvert.SerializeObject(deserialized, Formatting.Indented)); } diff --git a/src/Umbraco.Tests/Web/Controllers/ContentControllerTests.cs b/src/Umbraco.Tests/Web/Controllers/ContentControllerTests.cs index 881fe79053..f75371d203 100644 --- a/src/Umbraco.Tests/Web/Controllers/ContentControllerTests.cs +++ b/src/Umbraco.Tests/Web/Controllers/ContentControllerTests.cs @@ -24,6 +24,12 @@ using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.PublishedCache; using Umbraco.Web._Legacy.Actions; using Task = System.Threading.Tasks.Task; +using Umbraco.Core.Dictionary; +using Umbraco.Web.PropertyEditors; +using System; +using Umbraco.Web.WebApi; +using Umbraco.Web.Trees; +using System.Globalization; namespace Umbraco.Tests.Web.Controllers { @@ -40,6 +46,8 @@ namespace Umbraco.Tests.Web.Controllers var userServiceMock = new Mock(); userServiceMock.Setup(service => service.GetUserById(It.IsAny())) .Returns((int id) => id == 1234 ? new User(1234, "Test", "test@test.com", "test@test.com", "", new List(), new int[0], new int[0]) : null); + userServiceMock.Setup(x => x.GetProfileById(It.IsAny())) + .Returns((int id) => id == 1234 ? new User(1234, "Test", "test@test.com", "test@test.com", "", new List(), new int[0], new int[0]) : null); userServiceMock.Setup(service => service.GetPermissionsForPath(It.IsAny(), It.IsAny())) .Returns(new EntityPermissionSet(123, new EntityPermissionCollection(new[] { @@ -54,23 +62,34 @@ namespace Umbraco.Tests.Web.Controllers var entityService = new Mock(); entityService.Setup(x => x.GetAllPaths(UmbracoObjectTypes.Document, It.IsAny())) - .Returns((UmbracoObjectTypes objType, int[] ids) => ids.Select(x => new TreeEntityPath {Path = $"-1,{x}", Id = x}).ToList()); + .Returns((UmbracoObjectTypes objType, int[] ids) => ids.Select(x => new TreeEntityPath { Path = $"-1,{x}", Id = x }).ToList()); var dataTypeService = new Mock(); dataTypeService.Setup(service => service.GetDataType(It.IsAny())) - .Returns(MockedDataType()); + .Returns(Mock.Of(type => type.Id == 9876 && type.Name == "text")); + dataTypeService.Setup(service => service.GetDataType(-87)) //the RTE + .Returns(Mock.Of(type => type.Id == -87 && type.Name == "Rich text" && type.Configuration == new RichTextConfiguration())); + + var langService = new Mock(); + langService.Setup(x => x.GetAllLanguages()).Returns(new[] { + Mock.Of(x => x.IsoCode == "en-US"), + Mock.Of(x => x.IsoCode == "es-ES"), + Mock.Of(x => x.IsoCode == "fr-FR") + }); + + var textService = new Mock(); + textService.Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny>())).Returns(""); Container.RegisterSingleton(f => Mock.Of()); Container.RegisterSingleton(f => userServiceMock.Object); Container.RegisterSingleton(f => entityService.Object); Container.RegisterSingleton(f => dataTypeService.Object); + Container.RegisterSingleton(f => langService.Object); + Container.RegisterSingleton(f => textService.Object); + Container.RegisterSingleton(f => Mock.Of()); + Container.RegisterSingleton(f => new UmbracoApiControllerTypeCollection(new[] { typeof(ContentTreeController) })); } - private IDataType MockedDataType() - { - return Mock.Of(type => type.Id == 9876 && type.Name == "text"); - } - private MultipartFormDataContent GetMultiPartRequestContent(string json) { var multiPartBoundary = "----WebKitFormBoundary123456789"; @@ -89,14 +108,34 @@ namespace Umbraco.Tests.Web.Controllers }; } - private const string PublishJson1 = @"{ + private IContent GetMockedContent() + { + var content = MockedContent.CreateSimpleContent(MockedContentTypes.CreateSimpleContentType()); + content.Id = 123; + content.Path = "-1,123"; + //ensure things have ids + var ids = 888; + foreach (var g in content.PropertyGroups) + { + g.Id = ids; + ids++; + } + foreach (var p in content.PropertyTypes) + { + p.Id = ids; + ids++; + } + return content; + } + + private const string PublishJsonInvariant = @"{ ""id"": 123, ""contentTypeAlias"": ""page"", ""parentId"": -1, ""action"": ""save"", ""variants"": [ { - ""name"": null, + ""name"": ""asdf"", ""properties"": [ { ""id"": 1, @@ -104,10 +143,34 @@ namespace Umbraco.Tests.Web.Controllers ""value"": ""asdf"" } ], - ""culture"": ""en-US"" + ""culture"": null, + ""save"": true, + ""publish"": true + } + ] +}"; + + private const string PublishJsonVariant = @"{ + ""id"": 123, + ""contentTypeAlias"": ""page"", + ""parentId"": -1, + ""action"": ""save"", + ""variants"": [ + { + ""name"": ""asdf"", + ""properties"": [ + { + ""id"": 1, + ""alias"": ""title"", + ""value"": ""asdf"" + } + ], + ""culture"": ""en-US"", + ""save"": true, + ""publish"": true }, { - ""name"": null, + ""name"": ""asdf"", ""properties"": [ { ""id"": 1, @@ -115,7 +178,9 @@ namespace Umbraco.Tests.Web.Controllers ""value"": ""asdf"" } ], - ""culture"": ""fr-FR"" + ""culture"": ""fr-FR"", + ""save"": true, + ""publish"": true }, { ""name"": ""asdf"", @@ -142,12 +207,8 @@ namespace Umbraco.Tests.Web.Controllers { ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) { - //var content = MockedContent.CreateSimpleContent(MockedContentTypes.CreateSimpleContentType()); - //content.Id = 999999999; //this will not be found - //content.Path = "-1,999999999"; - var contentServiceMock = Mock.Get(Current.Services.ContentService); - contentServiceMock.Setup(x => x.GetById(123)).Returns(() => null); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => null); //do not find it var publishedSnapshot = Mock.Of(); var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); @@ -158,7 +219,7 @@ namespace Umbraco.Tests.Web.Controllers var runner = new TestRunner(Factory); var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, - content: GetMultiPartRequestContent(PublishJson1), + content: GetMultiPartRequestContent(PublishJsonInvariant), mediaTypeHeader: new MediaTypeWithQualityHeaderValue("multipart/form-data"), assertOkResponse: false); @@ -175,7 +236,7 @@ namespace Umbraco.Tests.Web.Controllers ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) { var contentServiceMock = Mock.Get(Current.Services.ContentService); - contentServiceMock.Setup(x => x.GetById(123)).Returns(() => null); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => GetMockedContent()); var publishedSnapshot = Mock.Of(); var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); @@ -184,9 +245,9 @@ namespace Umbraco.Tests.Web.Controllers return usersController; } - var json = JsonConvert.DeserializeObject(PublishJson1); + var json = JsonConvert.DeserializeObject(PublishJsonInvariant); //remove all save flaggs - ((JArray)json["variants"])[2]["save"] = false; + ((JArray)json["variants"])[0]["save"] = false; var runner = new TestRunner(Factory); var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, @@ -202,20 +263,52 @@ namespace Umbraco.Tests.Web.Controllers /// Returns 404 if any of the posted properties dont actually exist /// /// - [Test, Ignore("Not implemented yet")] + [Test] public async Task PostSave_Validate_Properties_Exist() { - //TODO: Make this work! to finish it, we need to include a property in the POST data that doesn't exist on the content type - // or change the content type below to not include one of the posted ones + ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) + { + var contentServiceMock = Mock.Get(Current.Services.ContentService); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => GetMockedContent()); + + var publishedSnapshot = Mock.Of(); + var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); + var usersController = new ContentController(publishedSnapshot, propertyEditorCollection); + Container.InjectProperties(usersController); + return usersController; + } + + var json = JsonConvert.DeserializeObject(PublishJsonInvariant); + //add a non-existent property to a variant being saved + var variantProps = (JArray)json["variants"].ElementAt(0)["properties"]; + variantProps.Add(JObject.FromObject(new + { + id = 2, + alias = "doesntExist", + value = "hello" + })); + + var runner = new TestRunner(Factory); + var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, + content: GetMultiPartRequestContent(JsonConvert.SerializeObject(json)), + mediaTypeHeader: new MediaTypeWithQualityHeaderValue("multipart/form-data"), + assertOkResponse: false); + + Assert.AreEqual(HttpStatusCode.NotFound, response.Item1.StatusCode); + } + + [Test] + public async Task PostSave_Simple_Invariant() + { + var content = GetMockedContent(); ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) { - var content = MockedContent.CreateSimpleContent(MockedContentTypes.CreateSimpleContentType()); - content.Id = 123; - content.Path = "-1,123"; var contentServiceMock = Mock.Get(Current.Services.ContentService); - contentServiceMock.Setup(x => x.GetById(123)).Returns(() => null); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => content); + contentServiceMock.Setup(x => x.Save(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new OperationResult(OperationResultType.Success, new Core.Events.EventMessages())); //success var publishedSnapshot = Mock.Of(); var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); @@ -226,14 +319,92 @@ namespace Umbraco.Tests.Web.Controllers var runner = new TestRunner(Factory); var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, - content: GetMultiPartRequestContent(PublishJson1), + content: GetMultiPartRequestContent(PublishJsonInvariant), mediaTypeHeader: new MediaTypeWithQualityHeaderValue("multipart/form-data"), assertOkResponse: false); - Assert.AreEqual(HttpStatusCode.NotFound, response.Item1.StatusCode); - - //var obj = JsonConvert.DeserializeObject>(response.Item2); - //Assert.AreEqual(0, obj.TotalItems); + Assert.AreEqual(HttpStatusCode.OK, response.Item1.StatusCode); + var display = JsonConvert.DeserializeObject(response.Item2); + Assert.AreEqual(1, display.Variants.Count()); + Assert.AreEqual(content.PropertyGroups.Count(), display.Variants.ElementAt(0).Tabs.Count()); + Assert.AreEqual(content.PropertyTypes.Count(), display.Variants.ElementAt(0).Tabs.ElementAt(0).Properties.Count()); } + + [Test] + public async Task PostSave_Validate_Empty_Name() + { + var content = GetMockedContent(); + + ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) + { + + var contentServiceMock = Mock.Get(Current.Services.ContentService); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => content); + contentServiceMock.Setup(x => x.Save(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new OperationResult(OperationResultType.Success, new Core.Events.EventMessages())); //success + + var publishedSnapshot = Mock.Of(); + var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); + var usersController = new ContentController(publishedSnapshot, propertyEditorCollection); + Container.InjectProperties(usersController); + return usersController; + } + + //clear out the name + var json = JsonConvert.DeserializeObject(PublishJsonInvariant); + json["variants"].ElementAt(0)["name"] = null; + + var runner = new TestRunner(Factory); + var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, + content: GetMultiPartRequestContent(JsonConvert.SerializeObject(json)), + mediaTypeHeader: new MediaTypeWithQualityHeaderValue("multipart/form-data"), + assertOkResponse: false); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.Item1.StatusCode); + var display = JsonConvert.DeserializeObject(response.Item2); + Assert.AreEqual(1, display.Errors.Count()); + Assert.IsTrue(display.Errors.ContainsKey("Variants[0].Name")); + //ModelState":{"Variants[0].Name":["Required"]} + } + + [Test] + public async Task PostSave_Validate_Variants_Empty_Name() + { + var content = GetMockedContent(); + + ApiController Factory(HttpRequestMessage message, UmbracoHelper helper) + { + + var contentServiceMock = Mock.Get(Current.Services.ContentService); + contentServiceMock.Setup(x => x.GetById(123)).Returns(() => content); + contentServiceMock.Setup(x => x.Save(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new OperationResult(OperationResultType.Success, new Core.Events.EventMessages())); //success + + var publishedSnapshot = Mock.Of(); + var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty())); + var usersController = new ContentController(publishedSnapshot, propertyEditorCollection); + Container.InjectProperties(usersController); + return usersController; + } + + //clear out one of the names + var json = JsonConvert.DeserializeObject(PublishJsonVariant); + json["variants"].ElementAt(0)["name"] = null; + + var runner = new TestRunner(Factory); + var response = await runner.Execute("Content", "PostSave", HttpMethod.Post, + content: GetMultiPartRequestContent(JsonConvert.SerializeObject(json)), + mediaTypeHeader: new MediaTypeWithQualityHeaderValue("multipart/form-data"), + assertOkResponse: false); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.Item1.StatusCode); + var display = JsonConvert.DeserializeObject(response.Item2); + Assert.AreEqual(2, display.Errors.Count()); + Assert.IsTrue(display.Errors.ContainsKey("Variants[0].Name")); + Assert.IsTrue(display.Errors.ContainsKey("_content_variant_en-US_")); + } + + //TODO: There are SOOOOO many more tests we should write - a lot of them to do with validation + } } diff --git a/src/Umbraco.Web.UI.Client/.jshintignore b/src/Umbraco.Web.UI.Client/.jshintignore deleted file mode 100644 index 97620d1820..0000000000 --- a/src/Umbraco.Web.UI.Client/.jshintignore +++ /dev/null @@ -1 +0,0 @@ -src/common/services/util.service.js \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js index 171fec88e5..eff1025e60 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js @@ -25,11 +25,6 @@ $scope.page.hideChangeVariant = infiniteMode ? true : false; $scope.allowOpen = true; - // add all editors to an editors array to support split view - $scope.editors = []; - $scope.initVariant = initVariant; - $scope.splitViewChanged = splitViewChanged; - function init(content) { if (infiniteMode) { @@ -55,17 +50,10 @@ // set first app to active // We need to track active $scope.content.apps[0].active = true; - - setActiveCulture(); - + resetVariantFlags(); } - /** This is called when the split view changes based on the umb-variant-content */ - function splitViewChanged() { - //send an event downwards - $scope.$broadcast("editors.content.splitViewChanged", { editors: $scope.editors }); - } /** * This will reset isDirty flags if save is true. @@ -91,123 +79,12 @@ $scope.content.variants[0].publish = false; } } - - function countDirtyVariants() { - var count = 0; - for (var i = 0; i < $scope.content.variants.length; i++) { - var v = $scope.content.variants[i]; - if (v.isDirty) { - count++; - } - } - return count; - } - + /** Returns true if the save/publish dialog should be shown when pressing the button */ function showSaveOrPublishDialog() { return $scope.content.variants.length > 1; } - /** - * The content item(s) are loaded into an array and this will set the active content item based on the current culture (query string). - * If the content item is invariant, then only one item exists in the array. - */ - function setActiveCulture() { - // set the active variant - var activeVariant = null; - _.each($scope.content.variants, function (v) { - if (v.language && v.language.culture === $scope.culture) { - v.active = true; - activeVariant = v; - } - else { - v.active = false; - } - }); - if (!activeVariant) { - // set the first variant to active - $scope.content.variants[0].active = true; - activeVariant = $scope.content.variants[0]; - } - - initVariant(activeVariant); - - //If there are no editors yet then create one with the current content. - //if there's already a main editor then update it with the current content. - if ($scope.editors.length === 0) { - var editor = { - content: activeVariant - }; - $scope.editors.push(editor); - } - else { - //this will mean there is only one - $scope.editors[0].content = activeVariant; - - if ($scope.editors.length > 1) { - //now re-sync any other editor content (i.e. if split view is open) - for (var s = 1; s < $scope.editors.length; s++) { - //get the variant from the scope model - var variant = _.find($scope.content.variants, function (v) { - return v.language.culture === $scope.editors[s].content.language.culture; - }); - $scope.editors[s].content = initVariant(variant); - } - } - - } - } - - function initVariant(variant) { - //The model that is assigned to the editor contains the current content variant along - //with a copy of the contentApps. This is required because each editor renders it's own - //header and content apps section and the content apps contains the view for editing content itself - //and we need to assign a view model to the subView so that it is scoped to the current - //editor so that split views work. - - //copy the apps from the main model if not assigned yet to the variant - if (!variant.apps) { - variant.apps = angular.copy($scope.content.apps); - } - - //if this is a variant has a culture/language than we need to assign the language drop down info - if (variant.language) { - //if the variant list that defines the header drop down isn't assigned to the variant then assign it now - if (!variant.variants) { - variant.variants = _.map($scope.content.variants, - function (v) { - return _.pick(v, "active", "language", "state"); - }); - } - else { - //merge the scope variants on top of the header variants collection (handy when needing to refresh) - angular.extend(variant.variants, - _.map($scope.content.variants, - function (v) { - return _.pick(v, "active", "language", "state"); - })); - } - - //ensure the current culture is set as the active one - for (var i = 0; i < variant.variants.length; i++) { - if (variant.variants[i].language.culture === variant.language.culture) { - variant.variants[i].active = true; - } - else { - variant.variants[i].active = false; - } - } - } - - //then assign the variant to a view model to the content app - var contentApp = _.find(variant.apps, function (a) { - return a.alias === "content"; - }); - contentApp.viewModel = variant; - - return variant; - } - function bindEvents() { //bindEvents can be called more than once and we don't want to have multiple bound events for (var e in evts) { @@ -249,9 +126,7 @@ init($scope.content); - if (!infiniteMode) { - syncTreeNode($scope.content, true); - } + syncTreeNode($scope.content, $scope.content.path, true); resetLastListPageNumber($scope.content); @@ -300,10 +175,12 @@ } /** Syncs the content item to it's tree node - this occurs on first load and after saving */ - function syncTreeNode(content, initialLoad) { - - var path = content.path; + function syncTreeNode(content, path, initialLoad) { + if (infiniteMode || !path) { + return; + } + if (!$scope.content.isChildOfListView) { navigationService.syncTree({ tree: $scope.treeAlias, path: path.split(","), forceReload: initialLoad !== true }).then(function (syncArgs) { $scope.page.menu.currentNode = syncArgs.node; @@ -341,9 +218,7 @@ //success init($scope.content); - if (!infiniteMode) { - syncTreeNode($scope.content); - } + syncTreeNode($scope.content, data.path); $scope.page.buttonGroupState = "success"; @@ -353,8 +228,7 @@ }, function (err) { - setActiveCulture(); - syncTreeNode($scope.content); + syncTreeNode($scope.content, $scope.content.path); //error if (err) { @@ -367,6 +241,19 @@ }); } + function clearNotifications(content) { + if (content.notifications) { + content.notifications = []; + } + if (content.variants) { + for (var i = 0; i < content.variants.length; i++) { + if (content.variants[i].notifications) { + content.variants[i].notifications = []; + } + } + } + } + function resetLastListPageNumber(content) { // We're using rootScope to store the page number for list views, so if returning to the list // we can restore the page. If we've moved on to edit a piece of content that's not the list or it's children @@ -376,6 +263,24 @@ } } + /** + * Used to clear the dirty state for successfully saved variants when not all variant saving was successful + * @param {any} variants + */ + function clearDirtyState(variants) { + for (var i = 0; i < variants.length; i++) { + var v = variants[i]; + if (v.notifications) { + var isSuccess = _.find(v.notifications, function (n) { + return n.type === 3; //this is a success notification + }); + if (isSuccess) { + v.isDirty = false; + } + } + } + } + if ($scope.page.isNew) { $scope.page.loading = true; @@ -438,9 +343,7 @@ init($scope.content); - if (!infiniteMode) { - syncTreeNode($scope.content); - } + syncTreeNode($scope.content, data.path); $scope.page.buttonGroupState = "success"; @@ -458,35 +361,40 @@ }; $scope.saveAndPublish = function () { - + clearNotifications($scope.content); // TODO: Add "..." to publish button label if there are more than one variant to publish - currently it just adds the elipses if there's more than 1 variant if (showSaveOrPublishDialog()) { //before we launch the dialog we want to execute all client side validations first if (formHelper.submitForm({ scope: $scope, action: "publish" })) { var dialog = { + parentScope: $scope, view: "views/content/overlays/publish.html", variants: $scope.content.variants, //set a model property for the dialog skipFormValidation: true, //when submitting the overlay form, skip any client side validation submitButtonLabel: "Publish", submit: function (model) { model.submitButtonState = "busy"; - + clearNotifications($scope.content); //we need to return this promise so that the dialog can handle the result and wire up the validation response return performSave({ saveMethod: contentResource.publish, action: "publish", showNotifications: false }).then(function (data) { + //show all notifications manually here since we disabled showing them automatically in the save method + formHelper.showNotifications(data); + clearNotifications($scope.content); overlayService.close(); return $q.when(data); }, function (err) { + clearDirtyState($scope.content.variants); model.submitButtonState = "error"; //re-map the dialog model since we've re-bound the properties dialog.variants = $scope.content.variants; - - return $q.reject(err); + //don't reject, we've handled the error + return $q.when(err); }); }, close: function (oldModel) { @@ -505,35 +413,40 @@ }; $scope.save = function () { - + clearNotifications($scope.content); // TODO: Add "..." to save button label if there are more than one variant to publish - currently it just adds the elipses if there's more than 1 variant if (showSaveOrPublishDialog()) { //before we launch the dialog we want to execute all client side validations first if (formHelper.submitForm({ scope: $scope, action: "save" })) { var dialog = { + parentScope: $scope, view: "views/content/overlays/save.html", variants: $scope.content.variants, //set a model property for the dialog skipFormValidation: true, //when submitting the overlay form, skip any client side validation submitButtonLabel: "Save", submit: function (model) { model.submitButtonState = "busy"; - + clearNotifications($scope.content); //we need to return this promise so that the dialog can handle the result and wire up the validation response return performSave({ saveMethod: $scope.saveMethod(), action: "save", showNotifications: false }).then(function (data) { + //show all notifications manually here since we disabled showing them automatically in the save method + formHelper.showNotifications(data); + clearNotifications($scope.content); overlayService.close(); return $q.when(data); }, function (err) { + clearDirtyState($scope.content.variants); model.submitButtonState = "error"; //re-map the dialog model since we've re-bound the properties dialog.variants = $scope.content.variants; - - return $q.reject(err); + //don't reject, we've handled the error + return $q.when(err); }); }, close: function (oldModel) { @@ -576,10 +489,6 @@ } }; - $scope.backToListView = function () { - $location.path($scope.page.listViewPath); - }; - $scope.restore = function (content) { $scope.page.buttonRestore = "busy"; @@ -685,12 +594,6 @@ } }; - $scope.$watch('culture', function (newVal, oldVal) { - if (newVal !== oldVal) { - setActiveCulture(); - } - }); - //ensure to unregister from all events! $scope.$on('$destroy', function () { for (var e in evts) { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontent.directive.js index cdc23ee985..5b00b4a254 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontent.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontent.directive.js @@ -2,133 +2,79 @@ 'use strict'; /** - * A directive to encapsulate each variant editor which includes the name header and all content apps for a given variant - * @param {any} $timeout - * @param {any} $location + * A component to encapsulate each variant editor which includes the name header and all content apps for a given variant */ - function variantContentDirective($timeout, $location) { + var umbVariantContent = { + templateUrl: 'views/components/content/umb-variant-content.html', + bindings: { + content: "<", + page: "<", + editor: "<", + editorIndex: "<", + editorCount: "<", + onCloseSplitView: "&", + onSelectVariant: "&", + onOpenSplitView: "&" + }, + controllerAs: 'vm', + controller: umbVariantContentController + }; + + function umbVariantContentController($scope, $element, $location) { - var directive = { - restrict: 'E', - replace: true, - templateUrl: 'views/components/content/umb-variant-content.html', - link: function (scope) { + var unsubscribe = []; - /** - * Adds a new editor to the editors array to show content in a split view - * @param {any} selectedVariant - */ - scope.openInSplitView = function (selectedVariant) { + var vm = this; - var selectedCulture = selectedVariant.language.culture; + vm.$postLink = postLink; + vm.$onDestroy = onDestroy; - //only the content app can be selected since no other apps are shown, and because we copy all of these apps - //to the "editors" we need to update this across all editors - for (var e = 0; e < scope.editors.length; e++) { - var editor = scope.editors[e]; - for (var i = 0; i < editor.content.apps.length; i++) { - var app = editor.content.apps[i]; - if (app.alias === "content") { - app.active = true; - } - else { - app.active = false; - } - } + vm.selectVariant = selectVariant; + vm.openSplitView = openSplitView; + vm.backToListView = backToListView; + + /** Called when the component has linked all elements, this is when the form controller is available */ + function postLink() { + //set the content to dirty if the header changes + unsubscribe.push($scope.$watch("contentHeaderForm.$dirty", + function(newValue, oldValue) { + if (newValue === true) { + vm.editor.content.isDirty = true; } - - //Find the whole variant model based on the culture that was chosen - var variant = _.find(scope.content.variants, function (v) { - return v.language.culture === selectedCulture; - }); - - var editor = { - content: scope.initVariant({ variant: variant}) - }; - scope.editors.push(editor); - - //TODO: hacking animation states - these should hopefully be easier to do when we upgrade angular - editor.collapsed = true; - editor.loading = true; - $timeout(function () { - editor.collapsed = false; - editor.loading = false; - scope.onSplitViewChanged(); - }, 100); - }; - - /** - * Changes the currently selected variant - * @param {any} variantDropDownItem - */ - scope.selectVariant = function (variantDropDownItem) { - - var editorIndex = _.findIndex(scope.editors, function (e) { - return e === scope.editor; - }); - - //if the editor index is zero, then update the query string to track the lang selection, otherwise if it's part - //of a 2nd split view editor then update the model directly. - if (editorIndex === 0) { - //if we've made it this far, then update the query string - $location.search("cculture", variantDropDownItem.language.culture); - } - else { - //set all variant drop down items as inactive for this editor and then set the selected on as active - for (var i = 0; i < scope.editor.content.variants.length; i++) { - scope.editor.content.variants[i].active = false; - } - variantDropDownItem.active = true; - - //get the variant content model and initialize the editor with that - var variant = _.find(scope.content.variants, function (v) { - return v.language.culture === variantDropDownItem.language.culture; - }); - scope.editor.content = scope.initVariant({ variant: variant }); - } - }; - - /** Closes the split view */ - scope.closeSplitView = function () { - //TODO: hacking animation states - these should hopefully be easier to do when we upgrade angular - scope.editor.loading = true; - scope.editor.collapsed = true; - $timeout(function () { - var index = _.findIndex(scope.editors, function(e) { - return e === scope.editor; - }); - scope.editors.splice(index, 1); - scope.onSplitViewChanged(); - }, 400); - }; - - //set the content to dirty if the header changes - scope.$watch("contentHeaderForm.$dirty", - function (newValue, oldValue) { - if (newValue === true) { - scope.editor.content.isDirty = true; - } - }); - - }, - scope: { - //TODO: This should be turned into a proper component - - page: "=", - content: "=", - editor: "=", - editors: "=", - //TODO: I don't like having this callback defined and would like to make this directive a bit less - // coupled but right now don't have time - initVariant: "&", - onSplitViewChanged: "&" + })); + } + + function onDestroy() { + for (var i = 0; i < unsubscribe.length; i++) { + unsubscribe[i](); } + } + + function backToListView() { + $location.path(vm.page.listViewPath); }; - return directive; + /** + * Used to proxy a callback + * @param {any} variant + */ + function selectVariant(variant) { + if (vm.onSelectVariant) { + vm.onSelectVariant({ "variant": variant }); + } + } + /** + * Used to proxy a callback + * @param {any} variant + */ + function openSplitView(variant) { + if (vm.onOpenSplitView) { + vm.onOpenSplitView({ "variant": variant }); + } + } } - angular.module('umbraco.directives').directive('umbVariantContent', variantContentDirective); + angular.module('umbraco.directives').component('umbVariantContent', umbVariantContent); })(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontenteditors.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontenteditors.directive.js new file mode 100644 index 0000000000..5e2775de73 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbvariantcontenteditors.directive.js @@ -0,0 +1,280 @@ +(function () { + 'use strict'; + + /** + * A component for split view content editing + */ + var umbVariantContentEditors = { + templateUrl: 'views/components/content/umb-variant-content-editors.html', + bindings: { + page: "<", + content: "<", //TODO: Not sure if this should be = since we are changing the 'active' property of a variant + culture: "<" + }, + controllerAs: 'vm', + controller: umbVariantContentEditorsController + }; + + function umbVariantContentEditorsController($scope, $element, $location, $timeout) { + + var prevContentDateUpdated = null; + + var vm = this; + + vm.$onInit = onInit; + vm.$onChanges = onChanges; + vm.$doCheck = doCheck; + vm.$postLink = postLink; + + vm.openSplitView = openSplitView; + vm.closeSplitView = closeSplitView; + vm.selectVariant = selectVariant; + + //Used to track how many content views there are (for split view there will be 2, it could support more in theory) + vm.editors = []; + + /** Called when the component initializes */ + function onInit() { + prevContentDateUpdated = angular.copy(vm.content.updateDate); + setActiveCulture(); + } + + /** Called when the component has linked all elements, this is when the form controller is available */ + function postLink() { + + } + + /** + * Watch for model changes + * @param {any} changes + */ + function onChanges(changes) { + + if (changes.culture && !changes.culture.isFirstChange() && changes.culture.currentValue !== changes.culture.previousValue) { + setActiveCulture(); + } + } + + /** Allows us to deep watch whatever we want - executes on every digest cycle */ + function doCheck() { + if (!angular.equals(vm.content.updateDate, prevContentDateUpdated)) { + setActiveCulture(); + prevContentDateUpdated = angular.copy(vm.content.updateDate); + } + } + + /** This is called when the split view changes based on the umb-variant-content */ + function splitViewChanged() { + //send an event downwards + $scope.$broadcast("editors.content.splitViewChanged", { editors: vm.editors }); + } + + /** + * Set the active variant based on the current culture (query string) + */ + function setActiveCulture() { + // set the active variant + var activeVariant = null; + _.each(vm.content.variants, function (v) { + if (v.language && v.language.culture === vm.culture) { + v.active = true; + activeVariant = v; + } + else { + v.active = false; + } + }); + if (!activeVariant) { + // Set the first variant to active if we can't find it. + // If the content item is invariant, then only one item exists in the array. + vm.content.variants[0].active = true; + activeVariant = vm.content.variants[0]; + } + + insertVariantEditor(0, initVariant(activeVariant)); + + if (vm.editors.length > 1) { + //now re-sync any other editor content (i.e. if split view is open) + for (var s = 1; s < vm.editors.length; s++) { + //get the variant from the scope model + var variant = _.find(vm.content.variants, function (v) { + return v.language.culture === vm.editors[s].content.language.culture; + }); + vm.editors[s].content = initVariant(variant); + } + } + + } + + /** + * Updates the editors collection for a given index for the specified variant + * @param {any} index + * @param {any} variant + */ + function insertVariantEditor(index, variant) { + + var variantCulture = variant.language ? variant.language.culture : "invariant"; + + //check if the culture at the index is the same, if it's null an editor will be added + var currentCulture = vm.editors.length === 0 || vm.editors.length <= index ? null : vm.editors[index].culture; + + if (currentCulture !== variantCulture) { + //Not the current culture which means we need to modify the array. + //NOTE: It is not good enough to just replace the `content` object at a given index in the array + // since that would mean that directives are not re-initialized. + vm.editors.splice(index, 1, { + content: variant, + //used for "track-by" ng-repeat + culture: variantCulture + }); + } + else { + //replace the editor for the same culture + vm.editors[index].content = variant; + } + } + + function initVariant(variant) { + //The model that is assigned to the editor contains the current content variant along + //with a copy of the contentApps. This is required because each editor renders it's own + //header and content apps section and the content apps contains the view for editing content itself + //and we need to assign a view model to the subView so that it is scoped to the current + //editor so that split views work. + + //copy the apps from the main model if not assigned yet to the variant + if (!variant.apps) { + variant.apps = angular.copy(vm.content.apps); + } + + //if this is a variant has a culture/language than we need to assign the language drop down info + if (variant.language) { + //if the variant list that defines the header drop down isn't assigned to the variant then assign it now + if (!variant.variants) { + variant.variants = _.map(vm.content.variants, + function (v) { + return _.pick(v, "active", "language", "state"); + }); + } + else { + //merge the scope variants on top of the header variants collection (handy when needing to refresh) + angular.extend(variant.variants, + _.map(vm.content.variants, + function (v) { + return _.pick(v, "active", "language", "state"); + })); + } + + //ensure the current culture is set as the active one + for (var i = 0; i < variant.variants.length; i++) { + if (variant.variants[i].language.culture === variant.language.culture) { + variant.variants[i].active = true; + } + else { + variant.variants[i].active = false; + } + } + } + + //then assign the variant to a view model to the content app + var contentApp = _.find(variant.apps, function (a) { + return a.alias === "content"; + }); + contentApp.viewModel = variant; + + return variant; + } + /** + * Adds a new editor to the editors array to show content in a split view + * @param {any} selectedVariant + */ + function openSplitView(selectedVariant) { + + var selectedCulture = selectedVariant.language.culture; + + //only the content app can be selected since no other apps are shown, and because we copy all of these apps + //to the "editors" we need to update this across all editors + for (var e = 0; e < vm.editors.length; e++) { + var editor = vm.editors[e]; + for (var i = 0; i < editor.content.apps.length; i++) { + var app = editor.content.apps[i]; + if (app.alias === "content") { + app.active = true; + } + else { + app.active = false; + } + } + } + + //Find the whole variant model based on the culture that was chosen + var variant = _.find(vm.content.variants, function (v) { + return v.language.culture === selectedCulture; + }); + + insertVariantEditor(vm.editors.length, initVariant(variant)); + + //TODO: hacking animation states - these should hopefully be easier to do when we upgrade angular + editor.collapsed = true; + editor.loading = true; + $timeout(function () { + editor.collapsed = false; + editor.loading = false; + splitViewChanged(); + }, 100); + } + + /** Closes the split view */ + function closeSplitView(editorIndex) { + //TODO: hacking animation states - these should hopefully be easier to do when we upgrade angular + var editor = vm.editors[editorIndex]; + editor.loading = true; + editor.collapsed = true; + $timeout(function () { + vm.editors.splice(editorIndex, 1); + splitViewChanged(); + }, 400); + } + + /** + * Changes the currently selected variant + * @param {any} variant This is the model of the variant/language drop down item in the editor header + * @param {any} editorIndex The index of the editor being changed + */ + function selectVariant(variant, editorIndex) { + + var variantCulture = variant.language ? variant.language.culture : "invariant"; + + //if the editor index is zero, then update the query string to track the lang selection, otherwise if it's part + //of a 2nd split view editor then update the model directly. + if (editorIndex === 0) { + //If we've made it this far, then update the query string. + //The editor will respond to this query string changing. + $location.search("cculture", variant.language.culture); + } + else { + + //Update the 'active' variant for this editor + var editor = vm.editors[editorIndex]; + //set all variant drop down items as inactive for this editor and then set the selected one as active + for (var i = 0; i < editor.content.variants.length; i++) { + editor.content.variants[i].active = false; + } + variant.active = true; + + //get the variant content model and initialize the editor with that + var contentVariant = _.find(vm.content.variants, + function (v) { + return v.language.culture === variant.language.culture; + }); + editor.content = initVariant(contentVariant); + + //update the editors collection + insertVariantEditor(editorIndex, contentVariant); + + } + } + } + + angular.module('umbraco.directives').component('umbVariantContentEditors', umbVariantContentEditors); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js index 1d1c9d8f00..189bc8f997 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js @@ -209,6 +209,13 @@ Use this directive to construct a header inside the main editor window. function link(scope, el, attr, ctrl) { + if (!scope.serverValidationNameField) { + scope.serverValidationNameField = "Name"; + } + if (!scope.serverValidationAliasField) { + scope.serverValidationAliasField = "Alias"; + } + scope.vm = {}; scope.vm.dropdownOpen = false; scope.vm.currentVariant = ""; @@ -324,7 +331,9 @@ Use this directive to construct a header inside the main editor window. splitViewOpen: "=?", onOpenInSplitView: "&?", onCloseSplitView: "&?", - onSelectVariant: "&?" + onSelectVariant: "&?", + serverValidationNameField: "@?", + serverValidationAliasField: "@?" }, link: link }; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js index d339b4c0f3..6655bef4ca 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js @@ -412,7 +412,7 @@ Opens an overlay to show a custom YSOD.
(function() { 'use strict'; - function OverlayDirective($timeout, formHelper, overlayHelper, localizationService, $q) { + function OverlayDirective($timeout, formHelper, overlayHelper, localizationService, $q, $templateCache, $http, $compile) { function link(scope, el, attr, ctrl) { @@ -424,7 +424,8 @@ Opens an overlay to show a custom YSOD.
var numberOfOverlays = 0; var isRegistered = false; - var modelCopy = {}; + var modelCopy = {}; + var unsubscribe = []; function activate() { @@ -459,6 +460,21 @@ Opens an overlay to show a custom YSOD.
scope.view = "views/common/overlays/" + viewAlias + "/" + viewAlias + ".html"; } + //if a custom parent scope is defined then we need to manually compile the view + if (scope.parentScope) { + var element = el.find(".scoped-view"); + $http.get(scope.view, { cache: $templateCache }) + .then(function (response) { + var templateScope = scope.parentScope.$new(); + unsubscribe.push(function() { + templateScope.$destroy(); + }); + templateScope.model = scope.model; + element.html(response.data); + element.show(); + $compile(element.contents())(templateScope); + }); + } } } @@ -553,7 +569,7 @@ Opens an overlay to show a custom YSOD.
var newObject = {}; for (var key in object) { - if (key !== "event") { + if (key !== "event" && key !== "parentScope") { newObject[key] = angular.copy(object[key]); } } @@ -652,14 +668,14 @@ Opens an overlay to show a custom YSOD.
$q.when(scope.model.submit(model, modelCopy, scope.directive.enableConfirmButton)).then( function() { formHelper.resetForm({ scope: scope }); - }, angular.noop); + }); } else { unregisterOverlay(); //wrap in a when since we don't know if this is a promise or not $q.when(scope.model.submit(model, modelCopy, scope.directive.enableConfirmButton)).then( function() { formHelper.resetForm({ scope: scope }); - }, angular.noop); + }); } } @@ -684,8 +700,11 @@ Opens an overlay to show a custom YSOD.
}; - scope.$on('$destroy', function(){ - unregisterOverlay(); + unsubscribe.push(unregisterOverlay); + scope.$on('$destroy', function () { + for (var i = 0; i < unsubscribe.length; i++) { + unsubscribe[i](); + } }); activate(); @@ -701,7 +720,8 @@ Opens an overlay to show a custom YSOD.
ngShow: "=", model: "=", view: "=", - position: "@" + position: "@", + parentScope: "=?" }, link: link }; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/validation/showvalidationonsubmit.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/validation/showvalidationonsubmit.directive.js index c46a3a9f9a..3dc48573c7 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/validation/showvalidationonsubmit.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/validation/showvalidationonsubmit.directive.js @@ -3,16 +3,20 @@ function showValidationOnSubmit(serverValidationManager) { return { - require: "ngMessages", + require: ["ngMessages", "^^?valFormManager"], restrict: "A", - + scope: { + form: "=?" + }, link: function (scope, element, attr, ctrl) { - //We can either get the form submitted status by the parent directive valFormManager (if we add a property to it) - //or we can just check upwards in the DOM for the css class (easier for now). + var formMgr = ctrl.length > 1 ? ctrl[1] : null; + + //We can either get the form submitted status by the parent directive valFormManager + //or we can check upwards in the DOM for the css class... lets try both :) //The initial hidden state can't always be hidden because when we switch variants in the content editor we cannot //reset the status. - var submitted = element.closest(".show-validation").length > 0; + var submitted = element.closest(".show-validation").length > 0 || (formMgr && formMgr.showValidation); if (!submitted) { element.hide(); } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valformmanager.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valformmanager.directive.js index 948702a4e3..35e9005cc6 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valformmanager.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valformmanager.directive.js @@ -19,7 +19,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location var SAVED_EVENT_NAME = "formSubmitted"; return { - require: "form", + require: ["form", "^^?valFormManager"], restrict: "A", controller: function($scope) { //This exposes an API for direct use with this directive @@ -35,6 +35,8 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location })); }; + this.showValidation = $scope.showValidation === true; + //Ensure to remove the event handlers when this instance is destroyted $scope.$on('$destroy', function () { for (var u in unsubscribe) { @@ -42,7 +44,10 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location } }); }, - link: function (scope, element, attr, formCtrl) { + link: function (scope, element, attr, ctrls) { + + var formCtrl = ctrls[0]; + var parentFormMgr = ctrls.length > 0 ? ctrls[1] : null; var labels = {}; @@ -96,8 +101,9 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location var isSavingNewItem = false; //we should show validation if there are any msgs in the server validation collection - if (serverValidationManager.items.length > 0) { + if (serverValidationManager.items.length > 0 || (parentFormMgr && parentFormMgr.showValidation)) { element.addClass(SHOW_VALIDATION_CLASS_NAME); + scope.showValidation = true; } var unsubscribe = []; @@ -105,7 +111,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location //listen for the forms saving event unsubscribe.push(scope.$on(SAVING_EVENT_NAME, function(ev, args) { element.addClass(SHOW_VALIDATION_CLASS_NAME); - + scope.showValidation = true; //set the flag so we can check to see if we should display the error. isSavingNewItem = $routeParams.create; })); @@ -114,7 +120,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location unsubscribe.push(scope.$on(SAVED_EVENT_NAME, function(ev, args) { //remove validation class element.removeClass(SHOW_VALIDATION_CLASS_NAME); - + scope.showValidation = false; //clear form state as at this point we retrieve new data from the server //and all validation will have cleared at this point formCtrl.$setPristine(); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js index 5fb6fe1625..e879c3aca0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js @@ -26,12 +26,14 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) { /** internal method process the saving of data and post processing the result */ - function saveContentItem(content, action, files, restApiUrl) { + function saveContentItem(content, action, files, restApiUrl, showNotifications) { + return umbRequestHelper.postSaveContent({ restApiUrl: restApiUrl, content: content, action: action, files: files, + showNotifications: showNotifications, dataFormatter: function (c, a) { return umbDataFormatter.formatContentPostData(c, a); } @@ -632,22 +634,23 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) { * * @param {Object} content The content item object with changes applied * @param {Bool} isNew set to true to create a new item or to update an existing - * @param {Array} files collection of files for the document + * @param {Array} files collection of files for the document + * @param {Bool} showNotifications an option to disable/show notifications (default is true) * @returns {Promise} resourcePromise object containing the saved content item. * */ - save: function (content, isNew, files) { + save: function (content, isNew, files, showNotifications) { var endpoint = umbRequestHelper.getApiUrl( "contentApiBaseUrl", "PostSave"); - return saveContentItem(content, "save" + (isNew ? "New" : ""), files, endpoint); + return saveContentItem(content, "save" + (isNew ? "New" : ""), files, endpoint, showNotifications); }, - saveBlueprint: function (content, isNew, files) { + saveBlueprint: function (content, isNew, files, showNotifications) { var endpoint = umbRequestHelper.getApiUrl( "contentApiBaseUrl", "PostSaveBlueprint"); - return saveContentItem(content, "save" + (isNew ? "New" : ""), files, endpoint); + return saveContentItem(content, "save" + (isNew ? "New" : ""), files, endpoint, showNotifications); }, /** @@ -674,15 +677,16 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) { * * @param {Object} content The content item object with changes applied * @param {Bool} isNew set to true to create a new item or to update an existing - * @param {Array} files collection of files for the document + * @param {Array} files collection of files for the document + * @param {Bool} showNotifications an option to disable/show notifications (default is true) * @returns {Promise} resourcePromise object containing the saved content item. * */ - publish: function (content, isNew, files) { + publish: function (content, isNew, files, showNotifications) { var endpoint = umbRequestHelper.getApiUrl( "contentApiBaseUrl", "PostSave"); - return saveContentItem(content, "publish" + (isNew ? "New" : ""), files, endpoint); + return saveContentItem(content, "publish" + (isNew ? "New" : ""), files, endpoint, showNotifications); }, diff --git a/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js index 1c0d159f34..6e6b0f3c0f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js @@ -66,7 +66,7 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica args.scope.busy = true; - return args.saveMethod(args.content, $routeParams.create, fileManager.getFiles()) + return args.saveMethod(args.content, $routeParams.create, fileManager.getFiles(), args.showNotifications) .then(function (data) { formHelper.resetForm({ scope: args.scope }); @@ -439,8 +439,7 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica var shouldIgnore = function (propName) { return _.some([ "variants", - "notifications", - "ModelState", + "tabs", "properties", "apps", @@ -599,8 +598,8 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica //add model state errors to notifications if (args.showNotifications) { - for (var e in modelState) { - notificationsService.error("Validation", modelState[e][0]); + for (var e in args.err.data.ModelState) { + notificationsService.error("Validation", args.err.data.ModelState[e][0]); } } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js index 228c885529..1619ca0623 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js @@ -136,8 +136,8 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ //create the callbacs based on whats been passed in. var callbacks = { - success: ((!opts || !opts.success) ? defaultSuccess : opts.success), - error: ((!opts || !opts.error) ? defaultError : opts.error) + success: (!opts || !opts.success) ? defaultSuccess : opts.success, + error: (!opts || !opts.error ? defaultError : opts.error) }; return httpPromise.then(function (response) { @@ -156,7 +156,7 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ //this is a JS/angular error that we should deal with return $q.reject({ errorMsg: response.message - }) + }); } //invoke the callback @@ -188,12 +188,22 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ errorMsg: result.errorMsg, data: result.data, status: result.status - }) + }); }); }, - /** Used for saving content/media/members specifically */ + /** + * @ngdoc method + * @name umbraco.resources.contentResource#postSaveContent + * @methodOf umbraco.resources.contentResource + * + * @description + * Used for saving content/media/members specifically + * + * @param {Object} args arguments object + * @returns {Promise} http promise object. + */ postSaveContent: function (args) { if (!args.restApiUrl) { @@ -211,6 +221,9 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ if (!args.dataFormatter) { throw "args.dataFormatter is a required argument"; } + if (args.showNotifications === null || args.showNotifications === undefined) { + args.showNotifications = true; + } //save the active tab id so we can set it when the data is returned. var activeTab = _.find(args.content.tabs, function (item) { @@ -246,7 +259,9 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ response.data.tabs[activeTabIndex].active = true; } - formHelper.showNotifications(response.data); + if (args.showNotifications) { + formHelper.showNotifications(response.data); + } //TODO: Do we need to pass the result through umbDataFormatter.formatContentGetData? Right now things work so not sure but we should check @@ -278,7 +293,7 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ } } - else { + else if (args.showNotifications) { formHelper.showNotifications(response.data); } diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html index 41ddd04584..bd71eb6691 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html @@ -5,19 +5,11 @@
- -
-
- - -
-
+ + + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content-editors.html b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content-editors.html new file mode 100644 index 0000000000..c479cf6502 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content-editors.html @@ -0,0 +1,18 @@ +
+
+ + + + +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content.html b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content.html index f0c1132005..f268e28abf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-variant-content.html @@ -1,32 +1,33 @@ 
- + -
+
- + navigation="vm.editor.content.apps" + variants="vm.editor.content.variants" + hide-change-variant="vm.page.hideChangeVariant" + on-back="vm.backToListView()" + show-back-button="vm.page.listViewPath !== null" + split-view-open="vm.editorCount > 1" + on-open-in-split-view="vm.openSplitView(variant)" + on-close-split-view="vm.onCloseSplitView()" + on-select-variant="vm.selectVariant(variant)" + server-validation-name-field="{{'Variants[' + vm.editorIndex + '].Name'}}">
-
- +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html index d4a540892e..3f575e36ed 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html @@ -34,8 +34,9 @@ ng-model="name" ng-class="{'name-is-empty': $parent.name===null || $parent.name===''}" umb-auto-focus - val-server-field="Name" - required /> + val-server-field="{{serverValidationNameField}}" + required + autocomplete="off" /> + server-validation-field="{{serverValidationAliasField}}"> diff --git a/src/Umbraco.Web.UI.Client/src/views/components/overlays/umb-overlay.html b/src/Umbraco.Web.UI.Client/src/views/components/overlays/umb-overlay.html index 5aab9c6335..feacf3adf8 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/overlays/umb-overlay.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/overlays/umb-overlay.html @@ -8,7 +8,8 @@
-
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 6970a7ba36..fe9ae4ebf9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -13,6 +13,8 @@ function contentCreateController($scope, $location, navigationService, blueprintConfig) { + + var mainCulture = $routeParams.mculture ? $routeParams.mculture : null; function initialize() { contentTypeResource.getAllowedTypes($scope.currentNode.id).then(function (data) { @@ -32,7 +34,10 @@ function contentCreateController($scope, $location .path("/content/content/edit/" + $scope.currentNode.id) .search("doctype", docType.alias) - .search("create", "true"); + .search("create", "true") + /* when we create a new node we want to make sure it uses the same + language as what is selected in the tree */ + .search("cculture", mainCulture); close(); } diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.edit.controller.js index bb7f198a36..cc55fbbf4d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.edit.controller.js @@ -30,7 +30,7 @@ function ContentEditController($scope, $rootScope, $routeParams, contentResource //This is so we can listen to changes on the cculture parameter since that will not cause a route change // and then we can pass in the updated culture to the editor $scope.$on('$routeUpdate', function (event, next) { - $scope.culture = next.params.cculture; + $scope.culture = next.params.cculture ? next.params.cculture : $routeParams.mculture; }); } diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publish.html b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publish.html index b49046feac..72565f6f71 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publish.html +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publish.html @@ -18,7 +18,6 @@ type="checkbox" ng-model="variant.publish" ng-change="vm.changeSelection(variant)" - ng-disabled="variant.active === true" style="margin-right: 8px;" val-server-field="{{variant.htmlId}}" />
@@ -27,7 +26,7 @@ * -
+
@@ -35,6 +34,10 @@
{{publishVariantSelectorForm.publishVariantSelector.errorMsg}}
+
+
{{notification.message}}
+
+
@@ -54,7 +57,15 @@ {{ variant.language.name }} *
- + +
+ +
+ +
+
{{notification.message}}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/save.html b/src/Umbraco.Web.UI.Client/src/views/content/overlays/save.html index dbe4c8fe24..51106e343a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/save.html +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/save.html @@ -18,7 +18,6 @@ type="checkbox" ng-model="variant.save" ng-change="vm.changeSelection(variant)" - ng-disabled="variant.active === true" style="margin-right: 8px;" val-server-field="{{variant.htmlId}}" />
@@ -27,14 +26,18 @@ * -
+
{{saveVariantSelectorForm.saveVariantSelector.errorMsg}}
- + +
+
{{notification.message}}
+
+
@@ -54,7 +57,14 @@ {{ variant.language.name }} * - + +
+ +
+ +
+
{{notification.message}}
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/media/media.edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/media/media.edit.controller.js index 688b44ae64..39fe794bf3 100644 --- a/src/Umbraco.Web.UI.Client/src/views/media/media.edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/media/media.edit.controller.js @@ -45,6 +45,10 @@ function mediaEditController($scope, $routeParams, $q, appState, mediaResource, /** Syncs the content item to it's tree node - this occurs on first load and after saving */ function syncTreeNode(content, path, initialLoad) { + if (infiniteMode) { + return; + } + if (!$scope.content.isChildOfListView) { navigationService.syncTree({ tree: "media", path: path.split(","), forceReload: initialLoad !== true }).then(function (syncArgs) { $scope.page.menu.currentNode = syncArgs.node; @@ -147,11 +151,8 @@ function mediaEditController($scope, $routeParams, $q, appState, mediaResource, editorState.set($scope.content); $scope.busy = false; - - // when don't want to sync the tree when the editor is open in infinite mode - if(!infiniteMode) { - syncTreeNode($scope.content, data.path); - } + + syncTreeNode($scope.content, data.path); init($scope.content); diff --git a/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml b/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml index f84c34c264..dea4c52bc5 100644 --- a/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml +++ b/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml @@ -100,7 +100,8 @@ + view="overlay.view" + parent-scope="overlay.parentScope"> Dictionary item saved Publishing failed because the parent page isn't published Content published - and visible on the website + and is visible on the website + %0% published and visible on the website Content saved Remember to publish to make changes visible + %0% saved Sent For Approval Changes have been sent for approval + %0% changes have been sent for approval Media saved Media saved without any errors Member saved diff --git a/src/Umbraco.Web/Editors/ContentController.cs b/src/Umbraco.Web/Editors/ContentController.cs index 8c3cc5f557..09126b80ee 100644 --- a/src/Umbraco.Web/Editors/ContentController.cs +++ b/src/Umbraco.Web/Editors/ContentController.cs @@ -8,6 +8,7 @@ using System.Text; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; +using System.Web.Http.ValueProviders; using AutoMapper; using Umbraco.Core; using Umbraco.Core.Logging; @@ -51,12 +52,14 @@ namespace Umbraco.Web.Editors { private readonly IPublishedSnapshotService _publishedSnapshotService; private readonly PropertyEditorCollection _propertyEditors; + private readonly Lazy> _allLangs; public ContentController(IPublishedSnapshotService publishedSnapshotService, PropertyEditorCollection propertyEditors) { if (publishedSnapshotService == null) throw new ArgumentNullException(nameof(publishedSnapshotService)); _publishedSnapshotService = publishedSnapshotService; _propertyEditors = propertyEditors ?? throw new ArgumentNullException(nameof(propertyEditors)); + _allLangs = new Lazy>(() => Services.LocalizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); } /// @@ -585,16 +588,22 @@ namespace Umbraco.Web.Editors // * Permissions are valid MapValuesForPersistence(contentItem); - //this a custom check for any variants not being flagged for Saving since we'll need to manually - //remove the ModelState validation for the Name - var variantIndex = 0; + //This a custom check for any variants not being flagged for Saving since we'll need to manually + //remove the ModelState validation for the Name. + //We are also tracking which cultures have an invalid Name + var variantCount = 0; + var variantNameErrors = new List(); foreach (var variant in contentItem.Variants) { - if (!variant.Save) + var msKey = $"Variants[{variantCount}].Name"; + if (ModelState.ContainsKey(msKey)) { - ModelState.Remove($"Variants[{variantIndex}].Name"); + if (!variant.Save) + ModelState.Remove(msKey); + else + variantNameErrors.Add(variant.Culture); } - variantIndex++; + variantCount++; } //We need to manually check the validation results here because: @@ -605,6 +614,16 @@ namespace Umbraco.Web.Editors // a message indicating this if (ModelState.IsValid == false) { + //another special case, if there's more than 1 variant, then we need to add the culture specific error + //messages based on the variants in error so that the messages show in the publish/save dialog + if (variantCount > 1) + { + foreach (var c in variantNameErrors) + { + AddCultureValidationError(c, "speechBubbles/contentCultureValidationError"); + } + } + if (IsCreatingAction(contentItem.Action)) { if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(contentItem) @@ -621,103 +640,162 @@ namespace Umbraco.Web.Editors } } - //if the model state is not valid we cannot publish so change it to save - switch (contentItem.Action) + //if there's only one variant and the model state is not valid we cannot publish so change it to save + if (variantCount == 1) { - case ContentSaveAction.Publish: - contentItem.Action = ContentSaveAction.Save; - break; - case ContentSaveAction.PublishNew: - contentItem.Action = ContentSaveAction.SaveNew; - break; + switch (contentItem.Action) + { + case ContentSaveAction.Publish: + contentItem.Action = ContentSaveAction.Save; + break; + case ContentSaveAction.PublishNew: + contentItem.Action = ContentSaveAction.SaveNew; + break; + } } + } //initialize this to successful var publishStatus = new PublishResult(null, contentItem.PersistedContent); - var wasCancelled = false; + bool wasCancelled; - if (contentItem.Action == ContentSaveAction.Save || contentItem.Action == ContentSaveAction.SaveNew) + //used to track successful notifications + var globalNotifications = new SimpleNotificationModel(); + var notifications = new Dictionary { - //save the item - var saveResult = saveMethod(contentItem.PersistedContent); + //global (non variant specific) notifications + [string.Empty] = globalNotifications + }; - wasCancelled = saveResult.Success == false && saveResult.Result == OperationResultType.FailedCancelledByEvent; - } - else if (contentItem.Action == ContentSaveAction.SendPublish || contentItem.Action == ContentSaveAction.SendPublishNew) + switch (contentItem.Action) { - var sendResult = Services.ContentService.SendToPublication(contentItem.PersistedContent, Security.CurrentUser.Id); - wasCancelled = sendResult == false; - } - else - { - PublishInternal(contentItem, ref publishStatus, ref wasCancelled); + case ContentSaveAction.Save: + case ContentSaveAction.SaveNew: + var saveResult = saveMethod(contentItem.PersistedContent); + wasCancelled = saveResult.Success == false && saveResult.Result == OperationResultType.FailedCancelledByEvent; + if (saveResult.Success) + { + if (variantCount > 1) + { + var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); + foreach (var c in contentItem.Variants.Where(x => x.Save && !cultureErrors.Contains(x.Culture)).Select(x => x.Culture).ToArray()) + { + AddSuccessNotification(notifications, c, + Services.TextService.Localize("speechBubbles/editContentSavedHeader"), + Services.TextService.Localize("speechBubbles/editVariantSavedText", new[] {_allLangs.Value[c].CultureName})); + } + } + else if (ModelState.IsValid) + { + globalNotifications.AddSuccessNotification( + Services.TextService.Localize("speechBubbles/editContentSavedHeader"), + Services.TextService.Localize("speechBubbles/editContentSavedText")); + } + } + break; + case ContentSaveAction.SendPublish: + case ContentSaveAction.SendPublishNew: + var sendResult = Services.ContentService.SendToPublication(contentItem.PersistedContent, Security.CurrentUser.Id); + wasCancelled = sendResult == false; + if (sendResult) + { + if (variantCount > 1) + { + var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); + foreach (var c in contentItem.Variants.Where(x => x.Save && !cultureErrors.Contains(x.Culture)).Select(x => x.Culture).ToArray()) + { + AddSuccessNotification(notifications, c, + Services.TextService.Localize("speechBubbles/editContentSendToPublish"), + Services.TextService.Localize("speechBubbles/editVariantSendToPublishText", new[] { _allLangs.Value[c].CultureName })); + } + } + else if (ModelState.IsValid) + { + globalNotifications.AddSuccessNotification( + Services.TextService.Localize("speechBubbles/editContentSendToPublish"), + Services.TextService.Localize("speechBubbles/editContentSendToPublishText")); + } + } + break; + case ContentSaveAction.Publish: + case ContentSaveAction.PublishNew: + PublishInternal(contentItem, ref publishStatus, out wasCancelled, out var successfulCultures); + //global notifications + AddMessageForPublishStatus(publishStatus, globalNotifications, successfulCultures); + //variant specific notifications + foreach (var c in successfulCultures) + { + AddMessageForPublishStatus(publishStatus, notifications.GetOrCreate(c), successfulCultures); + } + break; + default: + throw new ArgumentOutOfRangeException(); } //get the updated model var display = MapToDisplay(contentItem.PersistedContent); + //merge the tracked success messages with the outgoing model + display.Notifications.AddRange(globalNotifications.Notifications); + foreach (var v in display.Variants.Where(x => x.Language != null)) + { + if (notifications.TryGetValue(v.Language.IsoCode, out var n)) + v.Notifications.AddRange(n.Notifications); + } + //lasty, if it is not valid, add the modelstate to the outgoing object and throw a 403 HandleInvalidModelState(display); - //put the correct msgs in - switch (contentItem.Action) + if (wasCancelled) { - case ContentSaveAction.Save: - case ContentSaveAction.SaveNew: - if (wasCancelled == false) - { - display.AddSuccessNotification( - Services.TextService.Localize("speechBubbles/editContentSavedHeader"), - Services.TextService.Localize("speechBubbles/editContentSavedText")); - } - else - { - AddCancelMessage(display); - } - break; - case ContentSaveAction.SendPublish: - case ContentSaveAction.SendPublishNew: - if (wasCancelled == false) - { - display.AddSuccessNotification( - Services.TextService.Localize("speechBubbles/editContentSendToPublish"), - Services.TextService.Localize("speechBubbles/editContentSendToPublishText")); - } - else - { - AddCancelMessage(display); - } - break; - case ContentSaveAction.Publish: - case ContentSaveAction.PublishNew: - ShowMessageForPublishStatus(publishStatus, display); - break; + AddCancelMessage(display); + if (IsCreatingAction(contentItem.Action)) + { + //If the item is new and the operation was cancelled, we need to return a different + // status code so the UI can handle it since it won't be able to redirect since there + // is no Id to redirect to! + throw new HttpResponseException(Request.CreateValidationErrorResponse(display)); + } } - - //If the item is new and the operation was cancelled, we need to return a different - // status code so the UI can handle it since it won't be able to redirect since there - // is no Id to redirect to! - if (wasCancelled && IsCreatingAction(contentItem.Action)) - { - throw new HttpResponseException(Request.CreateValidationErrorResponse(display)); - } - + display.PersistedContent = contentItem.PersistedContent; return display; } + /// + /// Used to add success notifications globally and for the culture + /// + /// + /// + /// + /// + /// + /// global notifications will be shown if all variant processing is successful and the save/publish dialog is closed, otherwise + /// variant specific notifications are used to show success messagse in the save/publish dialog. + /// + private static void AddSuccessNotification(IDictionary notifications, string culture, string header, string msg) + { + //add the global notification (which will display globally if all variants are successfully processed) + notifications[string.Empty].AddSuccessNotification(header, msg); + //add the variant specific notification (which will display in the dialog if all variants are not successfully processed) + notifications.GetOrCreate(culture).AddSuccessNotification(header, msg); + } + /// /// Performs the publishing operation for a content item /// /// /// /// + /// + /// if the content is variant this will return an array of cultures that will be published (passed validation rules) + /// /// /// If this is a culture variant than we need to do some validation, if it's not we'll publish as normal /// - private void PublishInternal(ContentItemSave contentItem, ref PublishResult publishStatus, ref bool wasCancelled) + private void PublishInternal(ContentItemSave contentItem, ref PublishResult publishStatus, out bool wasCancelled, out string[] successfulCultures) { if (publishStatus == null) throw new ArgumentNullException(nameof(publishStatus)); @@ -726,40 +804,31 @@ namespace Umbraco.Web.Editors //its invariant, proceed normally publishStatus = Services.ContentService.SaveAndPublish(contentItem.PersistedContent, userId: Security.CurrentUser.Id); wasCancelled = publishStatus.Result == PublishResultType.FailedCancelledByEvent; + successfulCultures = Array.Empty(); } else { - var canPublish = true; - //All variants in this collection should have a culture if we get here! but we'll double check and filter here var cultureVariants = contentItem.Variants.Where(x => !x.Culture.IsNullOrWhiteSpace()).ToList(); - //check if we are publishing other variants and validate them - var allLangs = Services.LocalizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase); - - //validate any mandatory variants that are not in the list - var mandatoryLangs = Mapper.Map, IEnumerable>(allLangs.Values).Where(x => x.Mandatory); + //validate if we can publish based on the mandatory language requirements + var canPublish = ValidatePublishingMandatoryLanguages(contentItem, cultureVariants); - foreach (var lang in mandatoryLangs) + //Now check if there are validation errors on each variant. + //If validation errors are detected on a variant and it's state is set to 'publish', then we + //need to change it to 'save'. + //It is a requirement that this is performed AFTER ValidatePublishingMandatoryLanguages. + var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); + foreach (var variant in contentItem.Variants) { - //Check if a mandatory language is missing from being published - - var variant = cultureVariants.First(x => x.Culture == lang.IsoCode); - var isPublished = contentItem.PersistedContent.IsCulturePublished(lang.IsoCode); - var isPublishing = variant.Publish; - - if (!isPublished && !isPublishing) - { - //cannot continue publishing since a required language that is not currently being published isn't published - AddCultureValidationError(lang.IsoCode, allLangs, "speechBubbles/contentReqCulturePublishError"); - canPublish = false; - } + if (cultureErrors.Contains(variant.Culture)) + variant.Publish = false; } - + if (canPublish) { //try to publish all the values on the model - canPublish = PublishCulture(contentItem.PersistedContent, cultureVariants, allLangs); + canPublish = PublishCulture(contentItem.PersistedContent, cultureVariants); } if (canPublish) @@ -767,6 +836,7 @@ namespace Umbraco.Web.Editors //proceed to publish if all validation still succeeds publishStatus = Services.ContentService.SavePublishing(contentItem.PersistedContent, Security.CurrentUser.Id); wasCancelled = publishStatus.Result == PublishResultType.FailedCancelledByEvent; + successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).ToArray(); } else { @@ -774,18 +844,49 @@ namespace Umbraco.Web.Editors var saveResult = Services.ContentService.Save(contentItem.PersistedContent, Security.CurrentUser.Id); publishStatus = new PublishResult(PublishResultType.FailedCannotPublish, null, contentItem.PersistedContent); wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; + successfulCultures = Array.Empty(); } } } + /// + /// Validate if publishing is possible based on the mandatory language requirements + /// + /// + /// + /// + private bool ValidatePublishingMandatoryLanguages(ContentItemSave contentItem, IReadOnlyCollection cultureVariants) + { + var canPublish = true; + + //validate any mandatory variants that are not in the list + var mandatoryLangs = Mapper.Map, IEnumerable>(_allLangs.Value.Values).Where(x => x.Mandatory); + + foreach (var lang in mandatoryLangs) + { + //Check if a mandatory language is missing from being published + + var variant = cultureVariants.First(x => x.Culture == lang.IsoCode); + var isPublished = contentItem.PersistedContent.IsCulturePublished(lang.IsoCode); + var isPublishing = variant.Publish; + + if (isPublished || isPublishing) continue; + + //cannot continue publishing since a required language that is not currently being published isn't published + AddCultureValidationError(lang.IsoCode, "speechBubbles/contentReqCulturePublishError"); + canPublish = false; + } + + return canPublish; + } + /// /// This will call PublishCulture on the content item for each culture that needs to be published including the invariant culture /// /// /// - /// /// - private bool PublishCulture(IContent persistentContent, IEnumerable cultureVariants, IDictionary allLangs) + private bool PublishCulture(IContent persistentContent, IEnumerable cultureVariants) { foreach(var variant in cultureVariants.Where(x => x.Publish)) { @@ -793,7 +894,7 @@ namespace Umbraco.Web.Editors var valid = persistentContent.PublishCulture(variant.Culture); if (!valid) { - AddCultureValidationError(variant.Culture, allLangs, "speechBubbles/contentCultureValidationError"); + AddCultureValidationError(variant.Culture, "speechBubbles/contentCultureValidationError"); return false; } } @@ -805,16 +906,15 @@ namespace Umbraco.Web.Editors /// Adds a generic culture error for use in displaying the culture validation error in the save/publish dialogs /// /// - /// /// - private void AddCultureValidationError(string culture, IDictionary allLangs, string localizationKey) + private void AddCultureValidationError(string culture, string localizationKey) { var key = "_content_variant_" + culture + "_"; if (ModelState.ContainsKey(key)) return; - var errMsg = Services.TextService.Localize(localizationKey, new[] { allLangs[culture].CultureName }); + var errMsg = Services.TextService.Localize(localizationKey, new[] { _allLangs.Value[culture].CultureName }); ModelState.AddModelError(key, errMsg); } - + /// /// Publishes a document with a given ID /// @@ -839,7 +939,7 @@ namespace Umbraco.Web.Editors if (publishResult.Success == false) { var notificationModel = new SimpleNotificationModel(); - ShowMessageForPublishStatus(publishResult, notificationModel); + AddMessageForPublishStatus(publishResult, notificationModel); return Request.CreateValidationErrorResponse(notificationModel); } @@ -1194,19 +1294,11 @@ namespace Umbraco.Web.Editors if (!ModelState.IsValid) { //Add any culture specific errors here - var cultureErrors = ModelState.Keys - .Select(x => x.Split('.')) //split into parts - .Where(x => x.Length >= 3 && x[0] == "_Properties") //only choose _Properties errors - .Select(x => x[2]) //select the culture part - .Where(x => !x.IsNullOrWhiteSpace()) //if it has a value - .Distinct() - .ToList(); - - var allLangs = Services.LocalizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase); + var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); foreach (var cultureError in cultureErrors) { - AddCultureValidationError(cultureError, allLangs, "speechBubbles/contentCultureValidationError"); + AddCultureValidationError(cultureError, "speechBubbles/contentCultureValidationError"); } } @@ -1347,15 +1439,35 @@ namespace Umbraco.Web.Editors return toMove; } - private void ShowMessageForPublishStatus(PublishResult status, INotificationModel display) + /// + /// Adds notification messages to the outbound display model for a given published status + /// + /// + /// + /// + /// This is null when dealing with invariant content, else it's the cultures that were succesfully published + /// + private void AddMessageForPublishStatus(PublishResult status, INotificationModel display, string[] successfulCultures = null) { switch (status.Result) { case PublishResultType.Success: case PublishResultType.SuccessAlready: - display.AddSuccessNotification( + if (successfulCultures == null) + { + display.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editContentPublishedHeader"), Services.TextService.Localize("speechBubbles/editContentPublishedText")); + } + else + { + foreach (var c in successfulCultures) + { + display.AddSuccessNotification( + Services.TextService.Localize("speechBubbles/editContentPublishedHeader"), + Services.TextService.Localize("speechBubbles/editVariantPublishedText", new[] { _allLangs.Value[c].CultureName })); + } + } break; case PublishResultType.FailedPathNotPublished: display.AddWarningNotification( @@ -1367,12 +1479,14 @@ namespace Umbraco.Web.Editors AddCancelMessage(display, "publish", "speechBubbles/contentPublishedFailedByEvent"); break; case PublishResultType.FailedAwaitingRelease: + //TODO: We'll need to deal with variants here eventually display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedAwaitingRelease", new[] { $"{status.Content.Name} ({status.Content.Id})" }).Trim()); break; case PublishResultType.FailedHasExpired: + //TODO: We'll need to deal with variants here eventually display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedExpired", @@ -1482,7 +1596,6 @@ namespace Umbraco.Web.Editors /// Used to map an instance to a and ensuring a language is present if required /// /// - /// /// private ContentItemDisplay MapToDisplay(IContent content) { diff --git a/src/Umbraco.Web/ModelStateExtensions.cs b/src/Umbraco.Web/ModelStateExtensions.cs index 012728ef5a..48eb06c88a 100644 --- a/src/Umbraco.Web/ModelStateExtensions.cs +++ b/src/Umbraco.Web/ModelStateExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Web.Mvc; +using Umbraco.Core; namespace Umbraco.Web { @@ -37,20 +38,7 @@ namespace Umbraco.Web { return state.Where(v => v.Key.StartsWith(prefix + ".")).All(v => !v.Value.Errors.Any()); } - - - //NOTE: we used this alot in v5 when we had editors in MVC, this was really handy for knockout editors using JS - - ///// - ///// Adds an error to the model state that has to do with data validation, this is generally used for JSON responses - ///// - ///// - ///// - //public static void AddDataValidationError(this ModelStateDictionary state, string errorMessage) - //{ - // state.AddModelError("DataValidation", errorMessage); - //} - + /// /// Adds the error to model state correctly for a property so we can use it on the client side. /// @@ -66,6 +54,25 @@ namespace Umbraco.Web modelState.AddValidationError(result, "_Properties", propertyAlias, culture); } + /// + /// Returns a list of cultures that have property errors + /// + /// + /// + internal static IReadOnlyList GetCulturesWithPropertyErrors(this System.Web.Http.ModelBinding.ModelStateDictionary modelState) + { + //Add any culture specific errors here + var cultureErrors = modelState.Keys + .Select(x => x.Split('.')) //split into parts + .Where(x => x.Length >= 3 && x[0] == "_Properties") //only choose _Properties errors + .Select(x => x[2]) //select the culture part + .Where(x => !x.IsNullOrWhiteSpace()) //if it has a value + .Distinct() + .ToList(); + + return cultureErrors; + } + /// /// Adds the error to model state correctly for a property so we can use it on the client side. /// diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentVariationDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/ContentVariationDisplay.cs index 3078fe97da..90da6d6d04 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentVariationDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentVariationDisplay.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Runtime.Serialization; @@ -12,11 +13,12 @@ namespace Umbraco.Web.Models.ContentEditing /// Represents the variant info for a content item /// [DataContract(Name = "contentVariant", Namespace = "")] - public class ContentVariantDisplay : ITabbedContent, IContentProperties + public class ContentVariantDisplay : ITabbedContent, IContentProperties, INotificationModel { public ContentVariantDisplay() { Tabs = new List>(); + Notifications = new List(); } [DataMember(Name = "name", IsRequired = true)] @@ -59,6 +61,16 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "publishDate")] public DateTime? PublishDate { get; set; } - + + /// + /// This is used to add custom localized messages/strings to the response for the app to use for localized UI purposes. + /// + /// + /// The notifications assigned to a variant are currently only used to show custom messagse in the save/publish dialogs. + /// + [DataMember(Name = "notifications")] + [ReadOnly(true)] + public List Notifications { get; private set; } + } } diff --git a/src/Umbraco.Web/Models/ContentEditing/Notification.cs b/src/Umbraco.Web/Models/ContentEditing/Notification.cs index 495b80eb83..1ed20d27d4 100644 --- a/src/Umbraco.Web/Models/ContentEditing/Notification.cs +++ b/src/Umbraco.Web/Models/ContentEditing/Notification.cs @@ -20,9 +20,12 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "header")] public string Header { get; set; } + [DataMember(Name = "message")] public string Message { get; set; } + [DataMember(Name = "type")] public SpeechBubbleIcon NotificationType { get; set; } + } } diff --git a/src/Umbraco.Web/Models/Mapping/OwnerResolver.cs b/src/Umbraco.Web/Models/Mapping/OwnerResolver.cs index 671f915e3b..76937886a9 100644 --- a/src/Umbraco.Web/Models/Mapping/OwnerResolver.cs +++ b/src/Umbraco.Web/Models/Mapping/OwnerResolver.cs @@ -23,7 +23,8 @@ namespace Umbraco.Web.Models.Mapping public UserProfile Resolve(TPersisted source) { - return Mapper.Map(source.GetCreatorProfile(_userService)); + var profile = source.GetCreatorProfile(_userService); + return profile == null ? null : Mapper.Map(profile); } } } diff --git a/src/Umbraco.Web/Routing/UrlInfo.cs b/src/Umbraco.Web/Routing/UrlInfo.cs index cd77bb7006..01dbe4a0e1 100644 --- a/src/Umbraco.Web/Routing/UrlInfo.cs +++ b/src/Umbraco.Web/Routing/UrlInfo.cs @@ -11,7 +11,7 @@ namespace Umbraco.Web.Routing /// /// Initializes a new instance of the class. /// - private UrlInfo(string text, bool isUrl, string culture) + public UrlInfo(string text, bool isUrl, string culture) { IsUrl = isUrl; Text = text;