Skip to content

Instantly share code, notes, and snippets.

@SCullman
Last active August 29, 2015 14:22
Show Gist options
  • Save SCullman/4739e2aa14c089424ba4 to your computer and use it in GitHub Desktop.
Save SCullman/4739e2aa14c089424ba4 to your computer and use it in GitHub Desktop.
Typed Settings for DNN
using DotNetNuke.Common;
using DotNetNuke.Entities.Modules;
using DotNetNuke.Entities.Portals;
using System;
using System.Collections;
using System.Collections.Generic; { get { return Get(); } set { Set(value) } }
using System.Globalization;
using System.Runtime.CompilerServices;
namespace AnyNamespace
{
public class ModuleScopedSettings : StringBasedSettings
{
public ModuleScopedSettings(int moduleId, Hashtable moduleSettings)
: base(
name => moduleSettings[name] as string,
(name, value) => new ModuleController().UpdateModuleSetting(moduleId, name, value)
) { }
}
public class TabModuleScopedSettings : StringBasedSettings
{
internal static void UpdateSetting(int moduleId, int tabModuleId, string name, string value)
{
if (IsTabModuleSetting (name))
new ModuleController().UpdateTabModuleSetting(tabModuleId, SettingName(name), value);
else
new ModuleController().UpdateModuleSetting(moduleId, name, value);
}
internal static bool IsTabModuleSetting(string name)
{
return name != "Tab" && name.StartsWith("Tab") && Char.IsUpper(name[3]);
}
internal static string SettingName(string name)
{
return IsTabModuleSetting (name) ? name.Substring(3) : name);
}
public TabModuleScopedSettings(int moduleId, int tabModuleId, Hashtable moduleSettings)
: base(
name => moduleSettings[SettingName(name)] as string,
(name, value) => UpdateSetting(moduleId, tabModuleId, name, value)
) { }
}
public class PortalScopedSettings : StringBasedSettings
{
public PortalScopedSettings(int portalId)
: base(
name => PortalController.GetPortalSetting(name, portalId, ""),
(name, value) => PortalController.UpdatePortalSetting(portalId, name, value, true)
)
{ }
}
}
using DotNetNuke.Common;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.CompilerServices;
namespace AnyNameSpace
{
public interface ISettingsStore
{
T Get<T>(T @default = default(T), [CallerMemberName] string name = null);
void Set<T>(T value, [CallerMemberName] string name = null);
void Save();
}
public class StringBasedSettings : ISettingsStore
{
Dictionary<string, string> _cache;
Dictionary<string, Action> _dirty;
Func<string, string> _get;
Action<string, string> _set;
/// <param name="get">Function to get a value out of your setting store</param>
/// <param name="set">Action to save a value back to the setting store</param>
public StringBasedSettings(Func<string, string> get, Action<string, string> set)
{
Requires.NotNull("Get", get);
Requires.NotNull("Set", set);
_get = get;
_set = set;
_cache = new Dictionary<string, string>();
_dirty = new Dictionary<string, Action>();
}
// All changes to the settings are recorded and get only executed on demand
public void Save()
{
foreach (var save in _dirty.Values) save();
_dirty.Clear();
}
// CallerMemberName is used for the name of the setting. No more magic strings
public string Get(string @default = default(string), [CallerMemberName] string name = null)
{
Requires.NotNull("Name", name);
if (_cache.ContainsKey(name))
{
return _cache[name];
}
else
{
var value = _get(name) ?? @default;
_cache[name] = value;
return value;
}
}
public T Get<T>(T @default = default(T), [CallerMemberName] string name = null)
{
//required to behave Get and Get<string> the same
if (typeof(T) == typeof(string)) return (T)(object)Get((string)(object)@default, name);
var converter = TypeDescriptor.GetConverter(typeof(T));
Requires.NotNull("Converter for T", converter);
try
{
var defaultValueAsString = converter.ConvertToInvariantString(@default);
string value = Get(defaultValueAsString, name);
return (T)converter.ConvertFromInvariantString(value);
}
catch
{
return @default;
}
}
public void Set(string value, [CallerMemberName] string name = null)
{
Requires.NotNull("Name", name);
var modified = !(_cache.ContainsKey(name) && _cache[name] == value);
if (modified)
{
_cache[name] = value;
_dirty[name] = () => _set(name, value);
};
}
public void Set<T>(T value, [CallerMemberName] string name = null)
{
//required to behave Set and Set<string> the same
if (typeof(T) == typeof(string))
Set((string)(object)value, name);
else
{
var converter = TypeDescriptor.GetConverter(typeof(T));
Requires.NotNull("Converter for T", converter);
Set(converter.ConvertToInvariantString(value), name);
}
}
}
}
using dgzfp.tagung.dnn;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using FluentAssertions;
public class StringBasedSettingsTests
{
class SutClass : AnyNamespace.StringBasedSettings
{
public SutClass(Func<string, string> get, Action<string, string> set) : base(get, set) { }
public SutClass() : base(_get_dummy, _set_dummy) { }
public string AString { get { return Get(); } set { Set(value); } }
public int AnInt { get { return Get<int>(); } set { Set(value); } }
public bool ABool { get { return Get<bool>(); } set { Set(value); } }
public DateTime ADate { get { return Get<DateTime>(); } set { Set(value); } }
}
static Func<string, string> _get_dummy = name => null; //returns null
static Action<string, string> _set_dummy = (name, value) => { };//does nothing
[Fact]
public void Properties_can_be_initialized_from_external_getter()
{
Func<string, string> identity = name => name;
var sut = new SutClass(identity, _set_dummy);
sut.AString.Should().Be("AString");
}
[Fact]
public void External_setter_is_called_when_a_property_was_changed_and_saved()
{
var counter = 0;
Action<string, string> _set_counter = (name, value) => counter++;
var sut = new SutClass(_get_dummy, _set_counter);
sut.AString = "";
counter.Should().Be(0);
sut.Save();
counter.Should().Be(1);
}
[Fact]
public void Changing_multiple_properties_a_few_times_to_different_values_should_only_execute_setter_the_last_ones_on_save()
{
var counter = 0;
Action<string, string> _set_counter = (name, value) => counter++;
var sut = new SutClass(_get_dummy, _set_counter);
sut.AString = "A";
sut.AnInt = 1;
sut.AString = "B";
sut.AnInt = 2;
counter.Should().Be(0);
sut.Save();
counter.Should().Be(2);
}
[Fact]
public void Updates_to_properties_without_modifications_are_getting_ignored()
{
var counter = 0;
Action<string, string> _set_counter = (name, value) => counter++;
var sut = new SutClass(_get_dummy, _set_counter);
sut.AString = "A";
sut.AnInt = 1;
sut.Save();
counter.Should().Be(2);
sut.AString = "A";
sut.AnInt = 2;
sut.Save();
counter.Should().Be(3);
}
[Fact]
public void External_setter_is_called_only_once_per_change()
{
var counter = 0;
Action<string, string> _set_counter = (name, value) => counter++;
var sut = new SutClass(_get_dummy, _set_counter);
sut.AString = "";
sut.Save();
counter = 0;
sut.Save();
counter.Should().Be(0);
}
[Fact]
public void It_can_set_and_read_string_properties()
{
var input = "dnn-connect";
var sut = new SutClass();
sut.AString = input;
sut.AString.Should().Be(input);
}
[Fact]
public void It_can_set_and_read_integer_properties()
{
var input = 1;
var sut = new SutClass();
sut.AnInt = input;
Assert.Equal(input, sut.AnInt);
}
[Fact]
public void It_can_set_and_read_boolean_properties()
{
var input = true;
var sut = new SutClass();
sut.ABool = input;
Assert.Equal(input, sut.ABool);
}
[Fact]
public void It_can_set_and_read_date_properties()
{
var input = DateTime.Parse("06/06/2016");
var sut = new SutClass();
sut.ADate = input;
sut.ADate.Should().Be(input);
}
[Fact]
public void It_returns_default_values_if_external_getter_returns_null()
{
var sut = new SutClass();
sut.ADate.Should().Be(default(DateTime));
sut.AString.Should().Be(default(string));
sut.AnInt.Should().Be(default(int));
sut.ABool.Should().Be(default(bool));
}
}
using DotNetNuke.Common;
using DotNetNuke.Entities.Modules;
using DotNetNuke.Entities.Portals;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace AnyNamespace
{
// No more magic strings, setting names are retrieved directly from property names
// By convention, TabModuleScopedSettings stores properties beginning with Tab as TabModuleSetting
//
// Here we are inheriting from a base class. Quicker to write but harder to test
public class Settings : TabModuleScopedSettings
{
public Settings(int moduleId, Hashtable moduleSettings) : base(moduleId, moduleSettings) { }
//normal module setting
public bool ShowProjects { get { return Get(true); } set { Set(value); } } //Get<bool> not required as the dype can be retrieved from the default value
public string Project { get { return Get<string>(); } set { Set(value) } }
public string Description { get { return Get(); } set { Set(value) } } // Get() is a shortcut for Get<string>()
public string TableName { get { return Get(); } set { Set(value) } } // Not a TabModuleSetting as the fouth letter isn't uppercase
//tabmodulesetting
//TabProjects is saved as Project in TabModuleSettings and overrides the module setting Project.
public string TabProject { get { return Get(); } set { Set(value) } }
}
public class MyModule : PortalModuleBase
{
Settings _settings;
public new Settings Settings
{
get
{
if (_settings == null) _settings = new Settings(ModuleId, TabModuleId, base.Settings);
return _settings;
}
}
// Change Settings
public void ToggleShowProjects()
{
var current = Settings.ShowProjects;
Settings.ShowProjects = !current;
// Will only update the setting ShowProjects
Settings.Save();
}
}
}
// Please compare to
// https://github.com/DNN-Connect/Map/blob/master/Common/ModuleSettings.cs
//
// uses object composition, no inheritance from a base class is required
using System;
using System.Collections;
using DotNetNuke.Collections;
using DotNetNuke.Common.Utilities;
using DotNetNuke.Entities.Modules;
using AnyNamespace;
namespace Connect.DNN.Modules.Map.Common
{
public class ModuleSettings
{
internal ISettingsStore _store;
public ModuleSettings(int moduleId, Hashtable settings)
{
_store = new ModuleScopedSettings(moduleId,settings);
}
public string View { get { return _store.Get("Home"); } set { _store.Set(value) } }
public double MapOriginLat { get { return _store.Get(44.1); } set { _store.Set(value) } }
public double MapOriginLong { get { return _store.Get(3.07); } set { _store.Set(value) } }
public int Zoom { get { return _store.Get(8); } set { _store.Set(value) } }
public string MapWidth { get { return _store.Get("100%"); } set { _store.Set(value) } }
public string MapHeight { get { return _store.Get("500px"); } set { _store.Set(value) } }
public string Version = typeof(ModuleSettings).Assembly.GetName().Version.ToString();
public void SaveSettings()
{
_store.Save();
}
public static ModuleSettings GetSettings(ModuleInfo ctlModule)
{
return new ModuleSettings(ctlModule.ModuleId, clModule.ModuleSettings);
}
}
}
@donker
Copy link

donker commented Jun 19, 2015

Thanks Brian.

My suggestion would be to move all but the specific module settings class into the core. This would allow the developer to leverage this bit by having a pretty simple settings class in the module without much overhead. I'd say that custom setting names would be an edge case. But if there is an easy way to add it in as an option: by all means. If not, then I propose it would remain in the realm of "you can always do the old fashioned approach and do this yourself". The goal is to make life simpler for the module developer.

I agree about the custom converter. I've sometimes needed that.

Peter

@bdukes
Copy link

bdukes commented Jun 19, 2015

@donker the current solution allows using a custom setting name (my example above works with @SCullman's code), so nothing to worry about there. Though, I do worry that deriving the name from the setting name makes it more likely for tab/site/host settings to collide (in that I'd prefer the property to be ApiKey, while the setting name is MyModule_ApiKey, for example). But perhaps that can be addressed in those other settings classes.

@donker
Copy link

donker commented Jun 20, 2015

I use automatic prefixing as well in another solution. But tab, module, tabmodule and portalsettings now are stored in separate tables to allow them to hard link to their parent. So they're already separate, no?

@SCullman
Copy link
Author

Please see this gist as a kickstarter for discussions. At the time I started to copy fragments from an internal project into the gist, there was no intention for core modifications.

  • You don't need pre or postfixes for (tab)modulesettings. It might be useful for portalsettings, it might be an option within the constructor.
  • Most modules don't need tabmodulesettings at all. It was used a lot in FnL as it allowed me to setup different kind of views to the same data just my using "insert existing modules". I think this convention makes sense.
  • 90% of the modules don't need tabmodulesettings at all, modulesettings are just enough.
  • CustomConverters sounds like a nice idea for an an enhancement, should be possible. Anyway, do we need them in reality? If we need to support a type other than value types or strings, we should not hide that compexity.
  • There are still some issues. PortalSettings for example don't return null for empty/new settings, in that case the default values won't work.

For me, at least StringBasedSettings and both ModuleSettings and TabModuleSettings can make sense as part of the core.

@donker
Copy link

donker commented Jun 24, 2015

Right. For custom converters I'd love to see an example. But 99% of module settings break down as follows:

  • Strings: no need for conversion
  • Integers: trivial (de)serialization
  • Booleans: fairly trivial (de)serialization (camel cased true/false for example)
  • Floating point nrs: gets more tricky because you need to make sure it's culture neutral (comma vs decimal point)

Mostly it's the first 3 that are used with text boxes, checkboxes and dropdowns. And I agree with Stefan that we can ignore portal settings for now. I'm just focused on making module developer's life easier and this would cover the 99% use case IMHO.

Peter

@donker
Copy link

donker commented Jun 24, 2015

BTW, you could have the prefix as a property on the base class, defaulting to empty and override it when necessary, no?

@bdukes
Copy link

bdukes commented Jun 29, 2015

I've added this code as a branch in DNN's repo, so we can iterate on it via pull requests and try it out: https://github.com/dnnsoftware/Dnn.Platform/tree/research/typed-settings-API

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment