diff --git a/Directory.Packages.props b/Directory.Packages.props index 510f84898..1e7e528b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,69 +3,69 @@ true - + - - + + - + - - - + + + - - + + - - - - + + + + - + - - - + + + - + - + - - + + - + - - + + - - + + - - - - - - + + + + + + @@ -75,7 +75,7 @@ - - + + \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageTemplateService.cs b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageTemplateService.cs index 491e4fb24..c107e3104 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageTemplateService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageTemplateService.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Messages; +using Grand.Domain; +using Grand.Domain.Messages; namespace Grand.Business.Core.Interfaces.Messages; @@ -44,8 +45,11 @@ public interface IMessageTemplateService /// Gets all message templates /// /// Store identifier; pass "" to load all records - /// Message template list - Task> GetAllMessageTemplates(string storeId); + /// Keywords to filter by name or subject; pass "" to skip + /// Page index (0-based) + /// Page size + /// Paged list of message templates + Task> GetAllMessageTemplates(string storeId, string keywords = "", int pageIndex = 0, int pageSize = int.MaxValue); /// /// Create a copy of message template with all depended data diff --git a/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs b/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs index a9e64c48b..acabdda17 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs @@ -1,6 +1,7 @@ using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Messages; using Grand.Data; +using Grand.Domain; using Grand.Domain.Messages; using Grand.Infrastructure.Caching; using Grand.Infrastructure.Caching.Constants; @@ -136,28 +137,31 @@ public virtual async Task GetMessageTemplateByName(string messa /// Gets all message templates /// /// Store identifier; pass "" to load all records - /// Message template list - public virtual async Task> GetAllMessageTemplates(string storeId) + /// Keywords to filter by name or subject; pass "" to skip + /// Page index (0-based) + /// Page size + /// Paged list of message templates + public virtual async Task> GetAllMessageTemplates(string storeId, string keywords = "", int pageIndex = 0, int pageSize = int.MaxValue) { - var key = string.Format(CacheKey.MESSAGETEMPLATES_ALL_KEY, storeId); - return await _cacheBase.GetAsync(key, async () => - { - var query = from p in _messageTemplateRepository.Table - select p; - - query = query.OrderBy(t => t.Name); - - //Store acl - if (string.IsNullOrEmpty(storeId) || _accessControlConfig.IgnoreStoreLimitations) - return await Task.FromResult(query.ToList()); + var query = from p in _messageTemplateRepository.Table + select p; + //Store acl + if (!string.IsNullOrEmpty(storeId) && !_accessControlConfig.IgnoreStoreLimitations) query = from p in query where !p.LimitedToStores || p.Stores.Contains(storeId) select p; - query = query.OrderBy(t => t.Name); - return await Task.FromResult(query.ToList()); - }); + if (!string.IsNullOrEmpty(keywords)) + { + var lowerKeywords = keywords.ToLower(); + query = query.Where(t => t.Name.ToLower().Contains(lowerKeywords) || + (t.Subject != null && t.Subject.Contains(lowerKeywords, StringComparison.CurrentCultureIgnoreCase))); + } + + query = query.OrderBy(t => t.Name); + + return await PagedList.Create(query, pageIndex, pageSize); } /// diff --git a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj index 81006cd9d..d3de7b90d 100644 --- a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj +++ b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Plugins/Authentication.Google/Authentication.Google.csproj b/src/Plugins/Authentication.Google/Authentication.Google.csproj index a76e7720c..1db5fedda 100644 --- a/src/Plugins/Authentication.Google/Authentication.Google.csproj +++ b/src/Plugins/Authentication.Google/Authentication.Google.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/List.cshtml index 199f985f8..916688db4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/List.cshtml @@ -1,4 +1,5 @@ @model MessageTemplateListModel +@inject AdminAreaSettings adminAreaSettings @{ //page title ViewBag.Title = Loc["Admin.Content.MessageTemplates"]; @@ -75,15 +76,14 @@ // Cancel the changes this.cancelChanges(); }, + pageSize: @(adminAreaSettings.DefaultGridPageSize), serverPaging: true, serverFiltering: true, serverSorting: true }, pageable: { refresh: true, - numeric: false, - previousNext: false, - info: false + pageSizes: [@(adminAreaSettings.GridPageSizes)] }, editable: { confirmation: false, diff --git a/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs index b5d5de26d..cb1331e9f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs @@ -77,12 +77,12 @@ public async Task List() [HttpPost] public async Task List(DataSourceRequest command, MessageTemplateListModel model) { - var messageTemplates = await _messageTemplateService.GetAllMessageTemplates(model.SearchStoreId); + var messageTemplates = await _messageTemplateService.GetAllMessageTemplates( + model.SearchStoreId, + keywords: model.Name, + pageIndex: command.Page - 1, + pageSize: command.PageSize); - if (!string.IsNullOrEmpty(model.Name)) - messageTemplates = messageTemplates.Where - (x => x.Name.ToLowerInvariant().Contains(model.Name.ToLowerInvariant()) || - x.Subject.ToLowerInvariant().Contains(model.Name.ToLowerInvariant())).ToList(); var items = new List(); foreach (var x in messageTemplates) { @@ -103,7 +103,7 @@ public async Task List(DataSourceRequest command, MessageTemplate var gridModel = new DataSourceResult { Data = items, - Total = messageTemplates.Count + Total = messageTemplates.TotalCount }; return Json(gridModel); diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml new file mode 100644 index 000000000..566cc6135 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml @@ -0,0 +1,36 @@ +@model MessageTemplateModel +@{ + ViewBag.Title = Loc["Admin.Content.MessageTemplates.AddNew"]; + Layout = Constants.LayoutStore; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Content.MessageTemplates.AddNew"] + + @Html.ActionLink(Loc["Admin.Content.MessageTemplates.BackToList"], "List") + +
+
+
+ + +
+
+
+
+ +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml new file mode 100644 index 000000000..e47a3d174 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml @@ -0,0 +1,48 @@ +@model MessageTemplateModel +@{ + ViewBag.Title = Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"]; + Layout = Constants.LayoutStore; + var isReadOnly = (bool)(ViewBag.IsReadOnly ?? false); +} +
+ +
+
+
+
+
+ + @Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"] - @Model.Name + + @Html.ActionLink(Loc["Admin.Content.MessageTemplates.BackToList"], "List") + +
+
+
+ @if (!isReadOnly) + { + + + + + @Loc["Admin.Common.Delete"] + + } +
+
+
+
+ +
+
+
+
+
+@if (!isReadOnly) +{ + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml new file mode 100644 index 000000000..87ecae4b2 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml @@ -0,0 +1,144 @@ +@inject AdminAreaSettings adminAreaSettings +@{ + ViewBag.Title = Loc["Admin.Content.MessageTemplates"]; + Layout = Constants.LayoutStore; +} + +
+
+
+
+
+ + @Loc["Admin.Content.MessageTemplates"] +
+ +
+
+ + + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml new file mode 100644 index 000000000..8e3520ab3 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml @@ -0,0 +1,212 @@ +@using Grand.Domain.Messages +@using Microsoft.AspNetCore.Mvc.Razor +@using Constants = Grand.SharedUIResources.Constants +@model MessageTemplateModel + +
+ + + + + +@{ + Func + template = @
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ @foreach (var token in Model.AllowedTokens) + { + + } +
+ + +
+
+
+ +
+ + +
+
+ +
; +} + +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Id)) + { + + + } + else + { + + } +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ @foreach (var token in Model.AllowedTokens) + { + + } +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ + +
+
+
+
+
+ diff --git a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs new file mode 100644 index 000000000..9d80fa9c8 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs @@ -0,0 +1,306 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.SharedKernel; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Models.Messages; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[PermissionAuthorize(PermissionSystemName.MessageTemplates)] +public class MessageTemplateController( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + ILanguageService languageService, + ITranslationService translationService, + IMessageTokenProvider messageTokenProvider, + IDownloadService downloadService, + IContextAccessor contextAccessor) : BaseStoreController +{ + private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public IActionResult Index() + { + return RedirectToAction("List"); + } + + public IActionResult List() + { + return View(); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ListGlobal(DataSourceRequest command) + { + var allTemplates = await messageTemplateService.GetAllMessageTemplates(""); + var globalTemplates = allTemplates + .Where(t => !t.LimitedToStores) + .ToList(); + + var total = globalTemplates.Count; + var items = globalTemplates + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => x.ToModel()) + .ToList(); + + var gridModel = new DataSourceResult { + Data = items, + Total = total + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ListStore(DataSourceRequest command) + { + var allTemplates = await messageTemplateService.GetAllMessageTemplates(""); + var storeTemplates = allTemplates + .Where(t => t.LimitedToStores && t.Stores.Contains(CurrentStoreId)) + .ToList(); + + var total = storeTemplates.Count; + var items = storeTemplates + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => x.ToModel()) + .ToList(); + + var gridModel = new DataSourceResult { + Data = items, + Total = total + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new MessageTemplateModel { + AllowedTokens = messageTokenProvider.GetListOfAllowedTokens() + }; + + foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(MessageTemplateModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + // Prevent duplicate: check only for store-specific templates with this name for the current store. + // Pass keywords to pre-filter by name at the DB level, then verify exact match and store ownership. + var existingStoreTemplate = (await messageTemplateService.GetAllMessageTemplates("", keywords: model.Name)) + .FirstOrDefault(t => + t.Name == model.Name && + t.LimitedToStores && + t.Stores.Contains(CurrentStoreId)); + if (existingStoreTemplate != null) + { + ModelState.AddModelError("Name", translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) + model.AvailableEmailAccounts.Add(ea.ToModel()); + return View(model); + } + + var messageTemplate = model.ToEntity(); + if (!model.HasAttachedDownload) + messageTemplate.AttachedDownloadId = ""; + if (model.SendImmediately) + messageTemplate.DelayBeforeSend = null; + + // Assign to the current store + messageTemplate.LimitedToStores = true; + messageTemplate.Stores = [CurrentStoreId]; + + await messageTemplateService.InsertMessageTemplate(messageTemplate); + + Success(translationService.GetResource("Admin.Content.MessageTemplates.AddNew")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = messageTemplate.Id }); + } + + return RedirectToAction("List"); + } + + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); + if (messageTemplate == null) + return RedirectToAction("List"); + + // Block access to templates that belong to a different store + if (messageTemplate.LimitedToStores && !messageTemplate.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + // Global templates (LimitedToStores=false) are shown read-only + ViewBag.IsReadOnly = !messageTemplate.LimitedToStores; + + var model = messageTemplate.ToModel(); + model.SendImmediately = !model.DelayBeforeSend.HasValue; + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + + foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.BccEmailAddresses = messageTemplate.GetTranslation(x => x.BccEmailAddresses, languageId, false); + locale.Subject = messageTemplate.GetTranslation(x => x.Subject, languageId, false); + locale.Body = messageTemplate.GetTranslation(x => x.Body, languageId, false); + locale.EmailAccountId = messageTemplate.GetTranslation(x => x.EmailAccountId, languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(MessageTemplateModel model, bool continueEditing) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); + if (messageTemplate == null) + return RedirectToAction("List"); + + if (!messageTemplate.LimitedToStores || !messageTemplate.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + var prevAttachment = messageTemplate.AttachedDownloadId; + + if (ModelState.IsValid) + { + messageTemplate = model.ToEntity(messageTemplate); + if (!model.HasAttachedDownload) + messageTemplate.AttachedDownloadId = ""; + if (model.SendImmediately) + messageTemplate.DelayBeforeSend = null; + + // Keep it assigned to the current store + messageTemplate.LimitedToStores = true; + messageTemplate.Stores = [CurrentStoreId]; + + if (!string.IsNullOrEmpty(prevAttachment) && prevAttachment != messageTemplate.AttachedDownloadId) + { + var attachment = await downloadService.GetDownloadById(prevAttachment); + if (attachment != null) + await downloadService.DeleteDownload(attachment); + } + + await messageTemplateService.UpdateMessageTemplate(messageTemplate); + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Updated")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = messageTemplate.Id }); + } + + return RedirectToAction("List"); + } + + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); + if (messageTemplate == null) + return RedirectToAction("List"); + + if (!messageTemplate.LimitedToStores || !messageTemplate.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + await messageTemplateService.DeleteMessageTemplate(messageTemplate); + + if (!string.IsNullOrEmpty(messageTemplate.AttachedDownloadId)) + { + var attachment = await downloadService.GetDownloadById(messageTemplate.AttachedDownloadId); + if (attachment != null) + await downloadService.DeleteDownload(attachment); + } + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Deleted")); + return RedirectToAction("List"); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CopyTemplate(MessageTemplateModel model) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); + if (messageTemplate == null) + return RedirectToAction("List"); + + // Only allow copying global templates (LimitedToStores = false) + if (messageTemplate.LimitedToStores) + return RedirectToAction("List"); + + // Prevent duplicate: check if a store-specific template with the same name already exists for the current store + var existing = (await messageTemplateService.GetAllMessageTemplates("", keywords: messageTemplate.Name)) + .FirstOrDefault(t => t.Name == messageTemplate.Name && t.LimitedToStores && t.Stores.Contains(CurrentStoreId)); + if (existing != null) + { + Error(translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); + return RedirectToAction("List"); + } + + try + { + var newMessageTemplate = await messageTemplateService.CopyMessageTemplate(messageTemplate); + // Assign copy to the current store + newMessageTemplate.LimitedToStores = true; + newMessageTemplate.Stores = [CurrentStoreId]; + await messageTemplateService.UpdateMessageTemplate(newMessageTemplate); + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Copied")); + return RedirectToAction("Edit", new { id = newMessageTemplate.Id }); + } + catch (GrandException exc) + { + Error(exc.Message); + return RedirectToAction("List"); + } + } +} diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 4ed1399d6..527aba66d 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ