diff --git a/VirtoCommerce.LiquidThemeEngine/SettingsManager.cs b/VirtoCommerce.LiquidThemeEngine/SettingsManager.cs new file mode 100644 index 000000000..6b8b7d21e --- /dev/null +++ b/VirtoCommerce.LiquidThemeEngine/SettingsManager.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using VirtoCommerce.Storefront.Model.Common.Exceptions; + +namespace VirtoCommerce.LiquidThemeEngine +{ + public static class SettingsManager + { + public class Settings + { + public Preset CurrentPreset { get; set; } + + public IList Presets { get; set; } = new List(); + } + + public class Preset + { + public string Name { get; set; } + + public JObject Json { get; set; } + } + + public static JObject Merge(JObject baseJson, JObject currentJson) + { + if (baseJson == null) + { + throw new ArgumentNullException(nameof(baseJson)); + } + if (currentJson == null) + { + throw new ArgumentNullException(nameof(currentJson)); + } + + var baseSettings = ReadSettings(baseJson); + var currentSettings = ReadSettings(currentJson); + //Change the current preset for base doc according to head preset value if it specified + if (!string.IsNullOrEmpty(currentSettings.CurrentPreset.Name)) + { + baseSettings.CurrentPreset = baseSettings.Presets.FirstOrDefault(x => x.Name == currentSettings.CurrentPreset.Name); + } + var result = baseSettings.CurrentPreset?.Json ?? new JObject(); + result.Merge(currentSettings.CurrentPreset.Json, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Merge }); + return result; + } + + public static Settings ReadSettings(JObject json) + { + var result = new Settings + { + CurrentPreset = new Preset + { + Json = json + } + }; + + if (json.GetValue("presets") is JObject presetsJson) + { + var allPresetsJsonProperties = presetsJson.Children().Cast().ToList(); + foreach (var presetJsonProperty in allPresetsJsonProperties) + { + var preset = new Preset + { + Name = presetJsonProperty.Name, + Json = presetJsonProperty.Value as JObject + }; + result.Presets.Add(preset); + } + } + + var currentPresetJsonToken = json.GetValue("current"); + if (currentPresetJsonToken is JValue currentPresetJsonValue) + { + var presetName = currentPresetJsonValue.Value.ToString(); + var currentPresetJson = result.Presets.FirstOrDefault(x => x.Name == presetName)?.Json; + if (currentPresetJson == null && result.Presets.Any()) + { + throw new StorefrontException($"Setting preset with name '{presetName}' not found"); + } + result.CurrentPreset.Name = presetName; + result.CurrentPreset.Json = currentPresetJson ?? json; + } + if (currentPresetJsonToken is JObject) + { + result.CurrentPreset.Json = currentPresetJsonToken as JObject; + } + + return result; + } + } +} diff --git a/VirtoCommerce.LiquidThemeEngine/ShopifyLiquidThemeEngine.cs b/VirtoCommerce.LiquidThemeEngine/ShopifyLiquidThemeEngine.cs index cc2bfb116..7b32522c2 100644 --- a/VirtoCommerce.LiquidThemeEngine/ShopifyLiquidThemeEngine.cs +++ b/VirtoCommerce.LiquidThemeEngine/ShopifyLiquidThemeEngine.cs @@ -358,20 +358,18 @@ public IDictionary GetSettings(string defaultValue = null) JObject result; var baseThemeSettings = new JObject(); - var currentThemeSettings = result = GetCurrentSettingsPreset(InnerGetAllSettings(_themeBlobProvider, CurrentThemeSettingPath)); + var currentThemeSettings = result = InnerGetAllSettings(_themeBlobProvider, CurrentThemeSettingPath); //Try to load settings from base theme path and merge them with resources for local theme if ((_options.MergeBaseSettings || currentThemeSettings == null) && !string.IsNullOrEmpty(BaseThemeSettingPath)) { cacheItem.AddExpirationToken(new CompositeChangeToken(new[] { ThemeEngineCacheRegion.CreateChangeToken(), _themeBlobProvider.Watch(BaseThemeSettingPath) })); - result = baseThemeSettings = GetCurrentSettingsPreset(InnerGetAllSettings(_themeBlobProvider, BaseThemeSettingPath)); + baseThemeSettings = InnerGetAllSettings(_themeBlobProvider, BaseThemeSettingPath); } - if (_options.MergeBaseSettings) - { - result = baseThemeSettings; - result.Merge(currentThemeSettings ?? new JObject(), new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Merge }); - } + result = _options.MergeBaseSettings + ? SettingsManager.Merge(baseThemeSettings, currentThemeSettings ?? new JObject()) + : SettingsManager.ReadSettings(currentThemeSettings ?? new JObject()).CurrentPreset.Json; return result.ToObject>().ToDictionary(x => x.Key, x => x.Value).WithDefaultValue(defaultValue); }); @@ -393,7 +391,7 @@ public JObject ReadLocalization() if (BaseThemeLocalePath != null) { cacheItem.AddExpirationToken(new CompositeChangeToken(new[] { ThemeEngineCacheRegion.CreateChangeToken(), _themeBlobProvider.Watch(BaseThemeLocalePath + "/*") })); - result = InnerReadLocalization(_themeBlobProvider, BaseThemeLocalePath, WorkContext.CurrentLanguage); + result = InnerReadLocalization(_themeBlobProvider, BaseThemeLocalePath, WorkContext.CurrentLanguage) ?? new JObject(); } result.Merge(InnerReadLocalization(_themeBlobProvider, CurrentThemeLocalePath, WorkContext.CurrentLanguage) ?? new JObject(), new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Merge }); return result; @@ -476,36 +474,7 @@ private static JObject InnerGetAllSettings(IContentBlobProvider themeBlobProvide return result; } - /// - /// Get actual preset from config - /// - /// - private static JObject GetCurrentSettingsPreset(JObject allSettings) - { - var result = allSettings; - var currentPreset = allSettings.GetValue("current"); - if (currentPreset is JValue currentPresetValue) - { - var currentPresetName = currentPresetValue.Value.ToString(); - if (!(allSettings.GetValue("presets") is JObject presets) || !presets.Children().Any()) - { - throw new StorefrontException("Setting presets not defined"); - } - - IList allPresets = presets.Children().Cast().ToList(); - result = allPresets.FirstOrDefault(p => p.Name == currentPresetName)?.Value as JObject; - if (result == null) - { - throw new StorefrontException($"Setting preset with name '{currentPresetName}' not found"); - } - } - if (currentPreset is JObject preset) - { - result = preset; - } - - return result; - } + private string ReadTemplateByPath(string templatePath) { diff --git a/VirtoCommerce.Storefront.Tests/LiquidThemeEngine/ShopifyLiquidThemeEngineTests.cs b/VirtoCommerce.Storefront.Tests/LiquidThemeEngine/ShopifyLiquidThemeEngineTests.cs new file mode 100644 index 000000000..8bf6240f5 --- /dev/null +++ b/VirtoCommerce.Storefront.Tests/LiquidThemeEngine/ShopifyLiquidThemeEngineTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; +using Newtonsoft.Json.Linq; +using VirtoCommerce.LiquidThemeEngine; +using VirtoCommerce.Storefront.Model; +using VirtoCommerce.Storefront.Model.Caching; +using VirtoCommerce.Storefront.Model.StaticContent; +using VirtoCommerce.Storefront.Model.Stores; +using Xunit; + +namespace VirtoCommerce.Storefront.Tests.LiquidThemeEngine +{ + public class ShopifyLiquidThemeEngineTests: IDisposable + { + private enum DefaultThemeType + { + WithoutPresets, + WithPresets, + WithPresetsAndCurrentObject + } + + private static readonly string ThemesPath = "Themes"; + private static readonly string BaseThemePath = "odt\\default"; + private static readonly string CurrentThemePath = "odt\\current"; + private static readonly string SettingsPath = "config\\settings_data.json"; + + private static JObject DefaultSettingsWithoutPresets => JObject.Parse(@" + { + 'background_color': '#fff', + 'foreground_color': '#000' + } + "); + + private static JObject DefaultSettingsWithPresets => JObject.Parse(@" + { + 'current': 'Light', + 'presets': { + 'Dark': { + 'background_color': '#000', + 'foreground_color': '#fff' + }, + 'Light': { + 'background_color': '#fff', + 'foreground_color': '#000' + } + } + } + "); + + private static JObject DefaultSettingsWithPresetsAndCurrentObject => JObject.Parse(@" + { + 'current': { + 'background_color': '#fff', + 'foreground_color': '#000' + }, + 'presets': { + 'Dark': { + 'background_color': '#000', + 'foreground_color': '#fff' + }, + 'Light': { + 'background_color': '#fff', + 'foreground_color': '#000' + } + } + } + "); + + private static JObject CurrentSettingsWithoutSelectedPreset => JObject.Parse(@" + { + 'foreground_color': '#333' + } + "); + + private static JObject CurrentSettingsWithSelectedPreset => JObject.Parse(@" + { + 'current': 'Dark', + 'foreground_color': '#333' + } + "); + + private readonly StreamWriter _defaultThemeStreamWriter = new StreamWriter(new MemoryStream()) { AutoFlush = true }; + private readonly StreamWriter _currentThemeStreamWriter = new StreamWriter(new MemoryStream()) { AutoFlush = true }; + + private Stream DefaultThemeStream { get; set; } + private Stream CurrentThemeStream { get; set; } + + [Fact] + public void Settings_Without_Inheritance_Flat() + { + InitializeStreams(DefaultThemeType.WithoutPresets, false); + Check_Without_Inheritance(); + } + + [Fact] + public void Settings_Without_Inheritance_Presets() + { + InitializeStreams(DefaultThemeType.WithPresets, false); + Check_Without_Inheritance(); + } + + [Fact] + public void Settings_Without_Inheritance_Presets_And_Current_Object() + { + InitializeStreams(DefaultThemeType.WithPresetsAndCurrentObject, false); + Check_Without_Inheritance(); + } + + private void Check_Without_Inheritance() + { + var options = new LiquidThemeEngineOptions(); + var shopifyLiquidThemeEngine = GetThemeEngine(false, options); + var settings = shopifyLiquidThemeEngine.GetSettings(); + Assert.Equal("#fff", settings["background_color"]); + Assert.Equal("#000", settings["foreground_color"]); + } + + [Fact] + public void Settings_Inheritance_Backward_Compatibility_Base_Theme_Name() + { + var options = new LiquidThemeEngineOptions() + { +#pragma warning disable 618 + BaseThemeName = "odt" +#pragma warning restore 618 + }; + + Check_Inheritance_Backward_Compatibility(options); + } + + [Fact] + public void Settings_Inheritance_Backward_Compatibility_Base_Theme_Path_Without_Merge() + { + var options = new LiquidThemeEngineOptions() + { + BaseThemePath = "odt\\default" + }; + + Check_Inheritance_Backward_Compatibility(options); + } + + private void Check_Inheritance_Backward_Compatibility(LiquidThemeEngineOptions options) + { + var shopifyLiquidThemeEngine = GetThemeEngine(true, options); + InitializeStreams(DefaultThemeType.WithoutPresets, false); + var settings = shopifyLiquidThemeEngine.GetSettings(); + Assert.False(settings.ContainsKey("background_color")); + Assert.Equal("#333", settings["foreground_color"]); + } + + [Fact] + public void Settings_Inheritance_Both_Are_Flat() + { + InitializeStreams(DefaultThemeType.WithoutPresets, false); + Check_Colors_In_Merged_Settings(); + } + + [Fact] + public void Settings_Inheritance_Base_Has_Preset_Current_Is_Flat() + { + InitializeStreams(DefaultThemeType.WithPresets, false); + Check_Colors_In_Merged_Settings(); + } + + [Fact] + public void Settings_Inheritance_Current_Select_Preset_From_Base() + { + InitializeStreams(DefaultThemeType.WithPresets, true); + Check_Colors_In_Merged_Settings(true); + } + + private void Check_Colors_In_Merged_Settings(bool isDarkPreset = false) + { + var options = new LiquidThemeEngineOptions() + { + BaseThemePath = "odt\\default", + MergeBaseSettings = true + }; + var shopifyLiquidThemeEngine = GetThemeEngine(true, options); + var settings = shopifyLiquidThemeEngine.GetSettings(); + Assert.Equal(isDarkPreset ? "#000" : "#fff", settings["background_color"]); + Assert.Equal("#333", settings["foreground_color"]); + } + + private void InitializeStreams(DefaultThemeType defaultThemeType, bool currentThemeHasSelectedPreset) + { + JObject defaultThemeJson; + switch (defaultThemeType) + { + case DefaultThemeType.WithoutPresets: + defaultThemeJson = DefaultSettingsWithoutPresets; + break; + case DefaultThemeType.WithPresets: + defaultThemeJson = DefaultSettingsWithPresets; + break; + case DefaultThemeType.WithPresetsAndCurrentObject: + defaultThemeJson = DefaultSettingsWithPresetsAndCurrentObject; + break; + default: + throw new ArgumentOutOfRangeException(nameof(defaultThemeType), defaultThemeType, null); + } + + InitializeStream(_defaultThemeStreamWriter, out var defaultThemeStream, defaultThemeJson); + DefaultThemeStream = defaultThemeStream; + + InitializeStream(_currentThemeStreamWriter, out var currentThemeStream, currentThemeHasSelectedPreset ? CurrentSettingsWithSelectedPreset : CurrentSettingsWithoutSelectedPreset); + CurrentThemeStream = currentThemeStream; + } + + private void InitializeStream(StreamWriter writer, out Stream stream, T content) + { + // Clear + writer.BaseStream.Position = 0; + writer.Flush(); + // Write + writer.Write(content); + // Reset position + writer.BaseStream.Position = 0; + // Copy, because stream reader will automatically destroy it + stream = new MemoryStream(); + writer.BaseStream.CopyTo(stream); + stream.Position = 0; + } + + private IContentBlobProvider ContentBlobProvider + { + get + { + var mock = new Mock(); + var baseThemeSettingsPath = Path.Combine(ThemesPath, BaseThemePath, SettingsPath); + mock.Setup(service => service.PathExists(baseThemeSettingsPath)) + .Returns(() => true); + mock.Setup(service => service.OpenRead(baseThemeSettingsPath)) + .Returns(() => DefaultThemeStream); + var currentThemeSettingsPath = Path.Combine(ThemesPath, CurrentThemePath, SettingsPath); + mock.Setup(service => service.PathExists(currentThemeSettingsPath)) + .Returns(() => true); + mock.Setup(service => service.OpenRead(currentThemeSettingsPath)) + .Returns(() => CurrentThemeStream); + return mock.Object; + } + } + + public IStorefrontMemoryCache MemoryCache + { + get + { + var cacheEntry = Mock.Of(); + Mock.Get(cacheEntry).SetupGet(c => c.ExpirationTokens).Returns(new List()); + var memoryCacheMock = new Mock(); + memoryCacheMock + .Setup(x => x.CreateEntry(It.IsAny())) + .Returns((string key) => cacheEntry); + + memoryCacheMock.Setup(c => c.GetDefaultCacheEntryOptions()).Returns( + new MemoryCacheEntryOptions()); + return memoryCacheMock.Object; + } + } + + private IWorkContextAccessor GetWorkContextAccessor(bool useThemesInheritance) + { + var mock = new Mock(); + mock.Setup(service => service.WorkContext) + .Returns(() => new WorkContext + { + CurrentStore = new Store + { + Id = "odt", + ThemeName = useThemesInheritance ? "current" : "default" + } + }); + return mock.Object; + } + + private IHttpContextAccessor HttpContextAccessor + { + get + { + var mock = new Mock(); + mock.Setup(service => service.HttpContext.Request.Query["preview_mode"]) + .Returns(() => Enumerable.Empty().ToArray()); + return mock.Object; + } + } + + private ShopifyLiquidThemeEngine GetThemeEngine(bool useThemesInheritance, LiquidThemeEngineOptions options) + { + return new ShopifyLiquidThemeEngine(MemoryCache, GetWorkContextAccessor(useThemesInheritance), HttpContextAccessor, + null, ContentBlobProvider, null, new OptionsWrapper(options)); + } + + public void Dispose() + { + MemoryCache.Dispose(); + _defaultThemeStreamWriter.Dispose(); + _currentThemeStreamWriter.Dispose(); + } + } +}