diff --git a/Docs/extend_field_advanced.md b/Docs/extend_field_advanced.md index 3bff35c..84cbe20 100644 --- a/Docs/extend_field_advanced.md +++ b/Docs/extend_field_advanced.md @@ -30,4 +30,11 @@ Have a look at the [`UploadField`](../Source/Solution/FormEditor/Fields/UploadFi ## Supporting receipt emails In case the value stored by your field is not an email address, but you need your field to work as a source for receipt email addresses, you can implement the interface [`IEmailField`](../Source/Solution/FormEditor/Fields/IEmailField.cs) to translate your field value into the appropriate email addresses. -Have a look at the [`MemberInfoField`](../Source/Solution/FormEditor/Fields/MemberInfoField.cs) for an example. \ No newline at end of file +Have a look at the [`MemberInfoField`](../Source/Solution/FormEditor/Fields/MemberInfoField.cs) for an example. + +## Supporting statistics +You can easily include the submitted values for your field in the built-in statistics. All you need to do is implement an interface and Form Editor will do the rest. + +**Please note:** The statistics interfaces will most likely change over time, which might cause breaking changes for you when you upgrade Form Editor. + +At the time of writing, the only type of statistics that's supported is field value frequency statistics. The typical fields for this type of statistics are fields with predefined value ranges. To support field value frequency statistics, your field must implement [`IValueFrequencyStatisticsField`](../Source/Solution/FormEditor/Fields/Statistics/IValueFrequencyStatisticsField.cs). \ No newline at end of file diff --git a/Docs/img/statistics.png b/Docs/img/statistics.png new file mode 100644 index 0000000..307b052 Binary files /dev/null and b/Docs/img/statistics.png differ diff --git a/Docs/storage.md b/Docs/storage.md index 6962094..be57fff 100644 --- a/Docs/storage.md +++ b/Docs/storage.md @@ -49,4 +49,9 @@ You must supply the Elastic connection string as `FormEditor.ElasticIndex` in th ``` +## Supporting statistics +To make your index work with the build-in statistics it must implement the [`IStatisticsIndex`](../Source/Solution/FormEditor/Storage/Statistics/IStatisticsIndex.cs) interface. Nothing else is required - Form Editor will automatically enable statistics if it's backed by an index that implements this interface. +**Please note:** The `IStatisticsIndex` interface will most likely change over time, which might cause breaking changes for you when you upgrade Form Editor. + +None of the sample indexes currently support statistics, but you can have a look at the [`default index implementation`](../Source/Solution/FormEditor/Storage/Index.cs) for inspiration. \ No newline at end of file diff --git a/Docs/submissions.md b/Docs/submissions.md index 23e334c..2da8cdb 100644 --- a/Docs/submissions.md +++ b/Docs/submissions.md @@ -31,7 +31,7 @@ var formData = form.GetSubmittedValues( ); ``` -## Putting it all together +### Sample template Here's a full sample template that outputs the most recent form submissions to the users: ```xml @@ -84,8 +84,94 @@ Here's a full sample template that outputs the most recent form submissions to t ``` +## How about statistics? +If you'd like to work with the form submissions statistics on your frontend - you can! Once again, we start by retrieving the form model: + +```cs +// get the form model (named "form" on the content type) +var form = Model.Content.GetPropertyValue("form"); +``` + +The form model exposes the statistics for field value frequency through the method `GetFieldValueFrequencyStatistics()`. It can be used with the following parameters, all of which are optional: + +- `IEnumerable fieldNames`: The "form safe names" of the fields to retrieve statistics for. Default is all supported fields. +- `IPublishedContent content`: The content that holds the form. Default is current page. + +Mind you, only a subset of the built-in Form Editor fields support field value frequency statistics, namely the ones that have a predefined value range (like radio button group, select box, etc). + +### Sample template +Here's a full sample template that renders a bar chart (using [Google chart tools](https://developers.google.com/chart/)) of the field value frequencies for the field "genres": + +```xml +@using FormEditor; +@inherits Umbraco.Web.Mvc.UmbracoTemplatePage +@{ + Layout = null; + + // get the form model (named "form" on the content type) + var form = Model.Content.GetPropertyValue("form"); + + // get the field value frequency statistics for the field "genres" (if it exists) + var statistics = form.GetFieldValueFrequencyStatistics(new[] { "genres" }); + var fieldValueFrequencies = statistics.FieldValueFrequencies.FirstOrDefault(); +} + + + + @Model.Content.Name + + @if(fieldValueFrequencies != null) + { + + @* read all about Google chart tools at https://developers.google.com/chart/ *@ + + + } + + + @* this is where the chart will be rendered *@ +
+ + +``` + +The output should come out something like this: + +![Frontend rendering of statistics](img/statistics.png) + + ## Wait... what about async? -Nope, sorry. There's no public endpoint for retrieving form submissions asynchronously. It would be a major security problem to have that. +Nope, sorry. There's no public endpoint for retrieving form submissions or statistics asynchronously. It would be a major security problem to have that. ## Next step Onwards to [extending Form Editor](extend.md). \ No newline at end of file diff --git a/Grunt/package.json b/Grunt/package.json index 91f874e..743e8c7 100644 --- a/Grunt/package.json +++ b/Grunt/package.json @@ -17,6 +17,6 @@ "grunt-template": "^0.2.3" }, "meta": { - "version": "0.13.1.2" + "version": "0.13.2.1" } } diff --git a/README.md b/README.md index 57287b2..fd9a562 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Some highlights include: * Full control over the frontend rendering. * Full support for asynchronous postback, e.g. for AngularJS. * Easily extendable with custom fields. +* Built-in statistics for form submissions. * Editors can add texts and images alongside the form fields. * reCAPTCHA support. * Fully localizable. diff --git a/Samples/Advanced custom field/My.Fields.csproj b/Samples/Advanced custom field/My.Fields.csproj index 8282d51..6f39498 100644 --- a/Samples/Advanced custom field/My.Fields.csproj +++ b/Samples/Advanced custom field/My.Fields.csproj @@ -72,9 +72,9 @@ False ..\packages\Examine.0.1.68.0\lib\Examine.dll - - False - ..\packages\FormEditor.Binaries.0.13.1.2\lib\net40\FormEditor.dll + + ..\packages\FormEditor.Binaries.0.13.2.1\lib\net40\FormEditor.dll + True False diff --git a/Samples/Advanced custom field/packages.config b/Samples/Advanced custom field/packages.config index 403a511..683d476 100644 --- a/Samples/Advanced custom field/packages.config +++ b/Samples/Advanced custom field/packages.config @@ -4,7 +4,7 @@ - + diff --git a/Samples/Custom validation condition/My.Conditions.csproj b/Samples/Custom validation condition/My.Conditions.csproj index 7b36c44..7e37dc7 100644 --- a/Samples/Custom validation condition/My.Conditions.csproj +++ b/Samples/Custom validation condition/My.Conditions.csproj @@ -66,9 +66,9 @@ ..\packages\Examine.0.1.68.0\lib\Examine.dll True - - False - ..\packages\FormEditor.Binaries.0.13.1.2\lib\net40\FormEditor.dll + + ..\packages\FormEditor.Binaries.0.13.2.1\lib\net40\FormEditor.dll + True ..\packages\HtmlAgilityPack.1.4.9\lib\Net45\HtmlAgilityPack.dll diff --git a/Samples/Custom validation condition/packages.config b/Samples/Custom validation condition/packages.config index 403a511..683d476 100644 --- a/Samples/Custom validation condition/packages.config +++ b/Samples/Custom validation condition/packages.config @@ -4,7 +4,7 @@ - + diff --git a/Samples/Elastic storage index/FormEditor.ElasticIndex.csproj b/Samples/Elastic storage index/FormEditor.ElasticIndex.csproj index b79be21..47192d3 100644 --- a/Samples/Elastic storage index/FormEditor.ElasticIndex.csproj +++ b/Samples/Elastic storage index/FormEditor.ElasticIndex.csproj @@ -70,9 +70,9 @@ ..\packages\Examine.0.1.68.0\lib\Examine.dll True - - False - ..\packages\FormEditor.Binaries.0.13.1.2\lib\net40\FormEditor.dll + + ..\packages\FormEditor.Binaries.0.13.2.1\lib\net40\FormEditor.dll + True ..\packages\HtmlAgilityPack.1.4.9\lib\Net45\HtmlAgilityPack.dll diff --git a/Samples/Elastic storage index/packages.config b/Samples/Elastic storage index/packages.config index 9480a8d..67ee244 100644 --- a/Samples/Elastic storage index/packages.config +++ b/Samples/Elastic storage index/packages.config @@ -5,7 +5,7 @@ - + diff --git a/Samples/Event handling/My.Events.csproj b/Samples/Event handling/My.Events.csproj index 9b9e761..943a607 100644 --- a/Samples/Event handling/My.Events.csproj +++ b/Samples/Event handling/My.Events.csproj @@ -57,9 +57,9 @@ ..\packages\Examine.0.1.68.0\lib\Examine.dll - - False - ..\packages\FormEditor.Binaries.0.13.1.2\lib\net40\FormEditor.dll + + ..\packages\FormEditor.Binaries.0.13.2.1\lib\net40\FormEditor.dll + True ..\packages\HtmlAgilityPack.1.4.9\lib\Net45\HtmlAgilityPack.dll diff --git a/Samples/Event handling/packages.config b/Samples/Event handling/packages.config index 403a511..683d476 100644 --- a/Samples/Event handling/packages.config +++ b/Samples/Event handling/packages.config @@ -4,7 +4,7 @@ - + diff --git a/Samples/SQL storage index/FormEditor.SqlIndex.csproj b/Samples/SQL storage index/FormEditor.SqlIndex.csproj index 4ee4b83..2e61225 100644 --- a/Samples/SQL storage index/FormEditor.SqlIndex.csproj +++ b/Samples/SQL storage index/FormEditor.SqlIndex.csproj @@ -63,9 +63,9 @@ False ..\packages\Examine.0.1.68.0\lib\Examine.dll - - False - ..\packages\FormEditor.Binaries.0.13.1.2\lib\net40\FormEditor.dll + + ..\packages\FormEditor.Binaries.0.13.2.1\lib\net40\FormEditor.dll + True False diff --git a/Samples/SQL storage index/packages.config b/Samples/SQL storage index/packages.config index 403a511..683d476 100644 --- a/Samples/SQL storage index/packages.config +++ b/Samples/SQL storage index/packages.config @@ -4,7 +4,7 @@ - + diff --git a/Source/Solution/FormEditor/Api/PropertyEditorController.cs b/Source/Solution/FormEditor/Api/PropertyEditorController.cs index c2494c0..2fc88bc 100644 --- a/Source/Solution/FormEditor/Api/PropertyEditorController.cs +++ b/Source/Solution/FormEditor/Api/PropertyEditorController.cs @@ -7,7 +7,9 @@ using System.Web.Hosting; using System.Web.Http; using FormEditor.Fields; +using FormEditor.Fields.Statistics; using FormEditor.Storage; +using FormEditor.Storage.Statistics; using FormEditor.Umbraco; using FormEditor.Validation.Conditions; using Newtonsoft.Json; @@ -147,7 +149,9 @@ public object GetData(int id, int page, string sortField, bool sortDescending, s return null; } - var allFields = GetAllFieldsForDisplay(model, document); + var preValues = ContentHelper.GetPreValues(document, FormModel.PropertyEditorAlias); + var allFields = GetAllFieldsForDisplay(model, document, preValues); + var statisticsEnabled = ContentHelper.StatisticsEnabled(preValues); var index = IndexHelper.GetIndex(id); var fullTextIndex = index as IFullTextIndex; @@ -180,7 +184,8 @@ public object GetData(int id, int page, string sortField, bool sortDescending, s totalPages = totalPages, sortField = result.SortField, sortDescending = result.SortDescending, - supportsSearch = fullTextIndex != null + supportsSearch = fullTextIndex != null, + supportsStatistics = statisticsEnabled && index is IStatisticsIndex && allFields.StatisticsFields().Any() }; } @@ -191,12 +196,64 @@ public object GetMediaUrl(int id) return image != null ? new { id = image.Id, url = image.Url } : null; } - internal static List GetAllFieldsForDisplay(FormModel model, IContent document) + public object GetFieldValueFrequencyStatistics(int id) + { + var document = ContentHelper.GetById(id); + if(document == null) + { + return null; + } + var model = ContentHelper.GetFormModel(document); + if(model == null) + { + return null; + } + + var statisticsFields = model.AllValueFields().OfType().ToList(); + if(statisticsFields.Any() == false) + { + return null; + } + + var index = IndexHelper.GetIndex(id) as IStatisticsIndex; + if(index == null) + { + return null; + } + + var fieldValueFrequencyStatistics = index.GetFieldValueFrequencyStatistics(statisticsFields.StatisticsFieldNames()); + + return new + { + totalRows = fieldValueFrequencyStatistics.TotalRows, + fields = fieldValueFrequencyStatistics.FieldValueFrequencies + .Where(f => statisticsFields.Any(v => v.FormSafeName == f.Field)) + .Select(f => + { + var field = statisticsFields.First(v => v.FormSafeName == f.Field); + return new + { + name = field.Name, + formSafeName = field.FormSafeName, + multipleValuesPerEntry = field.MultipleValuesPerEntry, + values = f.Frequencies.Select(v => new + { + value = v.Value, + frequency = v.Frequency + }) + }; + }) + }; + } + + internal static List GetAllFieldsForDisplay(FormModel model, IContent document, IDictionary preValues = null) { var allFields = model.AllValueFields().ToList(); + preValues = preValues ?? ContentHelper.GetPreValues(document, FormModel.PropertyEditorAlias); + // show logged IPs? - if (ContentHelper.IpDisplayEnabled(document) && ContentHelper.IpLoggingEnabled(document)) + if (ContentHelper.IpDisplayEnabled(preValues) && ContentHelper.IpLoggingEnabled(preValues)) { // IPs are being logged, add a single line text field to retrieve IPs as a string allFields.Add(new TextBoxField { Name = "IP", FormSafeName = "_ip" }); diff --git a/Source/Solution/FormEditor/Fields/FieldWithFieldValues.cs b/Source/Solution/FormEditor/Fields/FieldWithFieldValues.cs index 5378147..1622473 100644 --- a/Source/Solution/FormEditor/Fields/FieldWithFieldValues.cs +++ b/Source/Solution/FormEditor/Fields/FieldWithFieldValues.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using System.Linq; +using FormEditor.Fields.Statistics; using Umbraco.Core.Models; namespace FormEditor.Fields { - public abstract class FieldWithFieldValues : FieldWithMandatoryValidation + public abstract class FieldWithFieldValues : FieldWithMandatoryValidation, IValueFrequencyStatisticsField { public FieldValue[] FieldValues { get; set; } @@ -20,7 +21,7 @@ protected internal override bool ValidateSubmittedValue(IEnumerable allCo return true; } - var submittedFieldValues = SubmittedValue.Split(','); + var submittedFieldValues = ExtractSubmittedValues(); FieldValues.ToList().ForEach(f => f.Selected = submittedFieldValues.Contains(f.Value)); // make sure all submitted values are actually defined as a field value (maybe some schmuck tampered with the options client side) @@ -36,5 +37,20 @@ public virtual bool IsMultiSelectEnabled { get { return false; } } + + public IEnumerable SubmittedValues + { + get { return ExtractSubmittedValues(); } + } + + private string[] ExtractSubmittedValues() + { + return SubmittedValue != null ? SubmittedValue.Split(',') : new string[] { }; + } + + public virtual bool MultipleValuesPerEntry + { + get { return IsMultiSelectEnabled; } + } } } \ No newline at end of file diff --git a/Source/Solution/FormEditor/Fields/SelectBoxField.cs b/Source/Solution/FormEditor/Fields/SelectBoxField.cs index c88757d..367d72b 100644 --- a/Source/Solution/FormEditor/Fields/SelectBoxField.cs +++ b/Source/Solution/FormEditor/Fields/SelectBoxField.cs @@ -22,5 +22,12 @@ public override bool IsMultiSelectEnabled get { return MultiSelect; } } public int? Size { get; set; } + + // force the statistics graphs to view this field type as multivalue, as it might + // have been at one time or another (it can be toggled on and off at will) + public override bool MultipleValuesPerEntry + { + get { return true; } + } } } diff --git a/Source/Solution/FormEditor/Fields/Statistics/FieldExtensions.cs b/Source/Solution/FormEditor/Fields/Statistics/FieldExtensions.cs new file mode 100644 index 0000000..9898151 --- /dev/null +++ b/Source/Solution/FormEditor/Fields/Statistics/FieldExtensions.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; + +namespace FormEditor.Fields.Statistics +{ + public static class FieldExtensions + { + public static IEnumerable StatisticsFields(this IEnumerable fields) + { + return fields == null + ? new IStatisticsField[] {} + : fields.OfType().ToArray(); + } + + public static IEnumerable StatisticsFieldNames(this IEnumerable fields) + { + return fields == null + ? new string[] {} + : fields.Select(f => f.FormSafeName).ToArray(); + } + } +} diff --git a/Source/Solution/FormEditor/Fields/Statistics/IStatisticsField.cs b/Source/Solution/FormEditor/Fields/Statistics/IStatisticsField.cs new file mode 100644 index 0000000..d311f7c --- /dev/null +++ b/Source/Solution/FormEditor/Fields/Statistics/IStatisticsField.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace FormEditor.Fields.Statistics +{ + /// + /// This is the base interface for a Form Editor field that supports statistics + /// + /// + /// Expect this interface to change over time as the demand for statistics grow + /// + public interface IStatisticsField + { + /// + /// The individual submitted values that should be put into the index for this field + /// + IEnumerable SubmittedValues { get; } + + /// + /// The field form safe name (should be inherited from FieldWithValue)u + /// + string FormSafeName { get; } + + /// + /// The field name (should be inherited from FieldWithValue) + /// + string Name { get; set; } + } +} diff --git a/Source/Solution/FormEditor/Fields/Statistics/IValueFrequencyStatisticsField.cs b/Source/Solution/FormEditor/Fields/Statistics/IValueFrequencyStatisticsField.cs new file mode 100644 index 0000000..dbe047b --- /dev/null +++ b/Source/Solution/FormEditor/Fields/Statistics/IValueFrequencyStatisticsField.cs @@ -0,0 +1,16 @@ +namespace FormEditor.Fields.Statistics +{ + /// + /// This interface describes a Form Editor field that supports field value statistics + /// + /// + /// Expect this interface to change over time as the demand for statistics grow + /// + public interface IValueFrequencyStatisticsField : IStatisticsField + { + /// + /// Whether or not field type can contain multiple values per entry + /// + bool MultipleValuesPerEntry { get; } + } +} \ No newline at end of file diff --git a/Source/Solution/FormEditor/FormEditor.csproj b/Source/Solution/FormEditor/FormEditor.csproj index b0a2784..457e87d 100644 --- a/Source/Solution/FormEditor/FormEditor.csproj +++ b/Source/Solution/FormEditor/FormEditor.csproj @@ -263,6 +263,9 @@ + + + @@ -316,6 +319,10 @@ + + + + diff --git a/Source/Solution/FormEditor/FormModel.cs b/Source/Solution/FormEditor/FormModel.cs index 86a7672..67337a2 100644 --- a/Source/Solution/FormEditor/FormModel.cs +++ b/Source/Solution/FormEditor/FormModel.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.ComponentModel; using System.IO; using System.Linq; using System.Net.Mail; @@ -10,7 +9,9 @@ using FormEditor.Data; using FormEditor.Events; using FormEditor.Fields; +using FormEditor.Fields.Statistics; using FormEditor.Storage; +using FormEditor.Storage.Statistics; using Umbraco.Core.Models; using Umbraco.Web; using Field = FormEditor.Fields.Field; @@ -73,6 +74,7 @@ public IEnumerable Rows private bool LogIp { get; set; } private bool StripHtml { get; set; } private bool DisableValidation { get; set; } + private bool UseStatistics { get; set; } #endregion @@ -217,6 +219,51 @@ public bool MaxSubmissionsExceeded(IPublishedContent content) return index.Count() >= MaxSubmissions.Value; } + public FieldValueFrequencyStatistics GetFieldValueFrequencyStatistics(IEnumerable fieldNames = null) + { + return GetFieldValueFrequencyStatistics(RequestedContent, fieldNames); + } + + public FieldValueFrequencyStatistics GetFieldValueFrequencyStatistics(IPublishedContent content, IEnumerable fieldNames = null) + { + if(content == null) + { + return new FieldValueFrequencyStatistics(0); + } + var fields = AllValueFields().StatisticsFields(); + if(fieldNames != null) + { + fieldNames = fields.StatisticsFieldNames().Intersect(fieldNames, StringComparer.OrdinalIgnoreCase); + } + else + { + fieldNames = fields.StatisticsFieldNames(); + } + if(fieldNames.Any() == false) + { + return new FieldValueFrequencyStatistics(0); + } + var index = IndexHelper.GetIndex(content.Id) as IStatisticsIndex; + if(index == null) + { + return new FieldValueFrequencyStatistics(0); + } + var statistics = index.GetFieldValueFrequencyStatistics(fieldNames); + // the statistics are indexed by field.FormSafeName - we need to reindex them by + // the fields themselves to support the frontend rendering + var result = new FieldValueFrequencyStatistics(statistics.TotalRows); + foreach(var fieldValueFrequency in statistics.FieldValueFrequencies) + { + var field = fields.FirstOrDefault(f => f.FormSafeName == fieldValueFrequency.Field); + if(field == null) + { + continue; + } + result.Add(field, fieldValueFrequency.Frequencies); + } + return result; + } + private HttpRequest Request { get { return HttpContext.Current.Request; } @@ -333,7 +380,17 @@ private Guid AddSubmittedValuesToIndex(IPublishedContent content, IEnumerable f.FormSafeName, f => f.SubmittedValues ?? new string[] {}); + statisticsIndex.Add(indexFields, indexFieldsForStatistics, rowId); + } + else + { + index.Add(indexFields, rowId); + } return rowId; } @@ -570,6 +627,7 @@ private void LoadPreValues(IPublishedContent content) LogIp = GetPreValueAsBoolean("logIp", preValueDictionary); StripHtml = GetPreValueAsBoolean("stripHtml", preValueDictionary); DisableValidation = GetPreValueAsBoolean("disableValidation", preValueDictionary); + UseStatistics = GetPreValueAsBoolean("enableStatistics", preValueDictionary); } catch(Exception ex) { diff --git a/Source/Solution/FormEditor/Properties/AssemblyInfo.cs b/Source/Solution/FormEditor/Properties/AssemblyInfo.cs index 233a99a..2e0c224 100644 --- a/Source/Solution/FormEditor/Properties/AssemblyInfo.cs +++ b/Source/Solution/FormEditor/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: -[assembly: AssemblyVersion("0.13.1.2")] -[assembly: AssemblyFileVersion("0.13.1.2")] +[assembly: AssemblyVersion("0.13.2.1")] +[assembly: AssemblyFileVersion("0.13.2.1")] diff --git a/Source/Solution/FormEditor/Storage/Index.cs b/Source/Solution/FormEditor/Storage/Index.cs index a2d4ae5..b6c789d 100644 --- a/Source/Solution/FormEditor/Storage/Index.cs +++ b/Source/Solution/FormEditor/Storage/Index.cs @@ -14,11 +14,12 @@ using LuceneDirectory = Lucene.Net.Store.Directory; using Lucene.Net.Search; using System.Globalization; +using FormEditor.Storage.Statistics; using Version = Lucene.Net.Util.Version; namespace FormEditor.Storage { - public class Index : IIndex, IFullTextIndex + public class Index : IIndex, IFullTextIndex, IStatisticsIndex { private readonly int _contentId; private LuceneDirectory _indexDirectory; @@ -32,6 +33,11 @@ public Index(int contentId) } public Guid Add(Dictionary fields, Guid rowId) + { + return Add(fields, null, rowId); + } + + public Guid Add(Dictionary fields, Dictionary> fieldsValuesForStatistics, Guid rowId) { var doc = new Document(); @@ -49,6 +55,17 @@ public Guid Add(Dictionary fields, Guid rowId) doc.Add(new LuceneField(FieldNameForSorting(field.Key), sortValue.ToLowerInvariant(), LuceneField.Store.NO, LuceneField.Index.NOT_ANALYZED)); } + if (fieldsValuesForStatistics != null) + { + foreach (var field in fieldsValuesForStatistics) + { + foreach (var value in field.Value) + { + doc.Add(new LuceneField(FieldNameForStatistics(field.Key), value, LuceneField.Store.NO, LuceneField.Index.NOT_ANALYZED)); + } + } + } + var writer = GetIndexWriter(); writer.AddDocument(doc); // optimize index for each 10 submits @@ -127,6 +144,34 @@ public Result Search(string searchQuery, string[] searchFields, string sortField return GetSearchResults(searchQuery, searchFields, sortField, sortDescending, count, skip); } + public FieldValueFrequencyStatistics GetFieldValueFrequencyStatistics(IEnumerable fieldNames) + { + var reader = GetIndexReader(); + var result = new FieldValueFrequencyStatistics(reader.NumDocs()); + foreach (var fieldName in fieldNames) + { + var fieldValueFrequencies = new List(); + + var stats = new TermRangeTermEnum(reader, FieldNameForStatistics(fieldName), null, null, true, true, null); + if (stats.Term() != null) + { + do + { + fieldValueFrequencies.Add(new FieldValueFrequency(stats.Term().Text(), stats.DocFreq())); + } + while (stats.Next()); + } + if (fieldValueFrequencies.Any()) + { + result.Add(fieldName, fieldValueFrequencies); + } + } + + reader.Close(); + + return result; + } + private Result GetSearchResults(string searchQuery, string [] searchFields, string sortField, bool sortDescending, int count, int skip) { var reader = GetIndexReader(); @@ -313,6 +358,11 @@ private static string FieldNameForSorting(string fieldName) return string.Format("{0}_sort", fieldName); } + private static string FieldNameForStatistics(string fieldName) + { + return string.Format("{0}_stats", fieldName); + } + private static Analyzer GetAnalyzer() { return new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_29); diff --git a/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencies.cs b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencies.cs new file mode 100644 index 0000000..eb71576 --- /dev/null +++ b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencies.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace FormEditor.Storage.Statistics +{ + public class FieldValueFrequencies + { + public FieldValueFrequencies(T field, IEnumerable frequencies) + { + Field = field; + Frequencies = frequencies; + } + + public T Field { get; private set; } + + public IEnumerable Frequencies { get; private set; } + } +} \ No newline at end of file diff --git a/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequency.cs b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequency.cs new file mode 100644 index 0000000..ccfbdc1 --- /dev/null +++ b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequency.cs @@ -0,0 +1,15 @@ +namespace FormEditor.Storage.Statistics +{ + public class FieldValueFrequency + { + public FieldValueFrequency(string value, int frequency) + { + Value = value; + Frequency = frequency; + } + + public string Value { get; private set; } + + public int Frequency { get; private set; } + } +} \ No newline at end of file diff --git a/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencyStatistics.cs b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencyStatistics.cs new file mode 100644 index 0000000..fc2c2d1 --- /dev/null +++ b/Source/Solution/FormEditor/Storage/Statistics/FieldValueFrequencyStatistics.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace FormEditor.Storage.Statistics +{ + public class FieldValueFrequencyStatistics + { + private readonly List> _fieldValueFrequencies; + + public FieldValueFrequencyStatistics(int totalRows) + { + TotalRows = totalRows; + _fieldValueFrequencies = new List>(); + } + + public void Add(T field, IEnumerable fieldValueFrequencies) + { + _fieldValueFrequencies.Add(new FieldValueFrequencies(field, fieldValueFrequencies)); + } + + public int TotalRows { get; private set; } + + public IEnumerable> FieldValueFrequencies + { + get { return _fieldValueFrequencies; } + } + } +} \ No newline at end of file diff --git a/Source/Solution/FormEditor/Storage/Statistics/IStatisticsIndex.cs b/Source/Solution/FormEditor/Storage/Statistics/IStatisticsIndex.cs new file mode 100644 index 0000000..07aeef4 --- /dev/null +++ b/Source/Solution/FormEditor/Storage/Statistics/IStatisticsIndex.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace FormEditor.Storage.Statistics +{ + /// + /// This interface describes a Form Editor storage index that supports statistics + /// + /// + /// Expect this interface to change over time as the demand for statistics grow + /// + public interface IStatisticsIndex + { + /// + /// Adds an entry to the index + /// + /// The field names and values to add + /// The field names and values to create statistics for + /// The ID of the entry to add + /// The ID of the form entry + Guid Add(Dictionary fields, Dictionary> fieldsValuesForStatistics, Guid rowId); + + /// + /// Gets the field value frequency statistics for specified fields + /// + /// The field names to get statistics for + /// The field value frequencies + FieldValueFrequencyStatistics GetFieldValueFrequencyStatistics(IEnumerable fieldNames); + } +} diff --git a/Source/Solution/FormEditor/Umbraco/ContentHelper.cs b/Source/Solution/FormEditor/Umbraco/ContentHelper.cs index 306b32d..19f8b96 100644 --- a/Source/Solution/FormEditor/Umbraco/ContentHelper.cs +++ b/Source/Solution/FormEditor/Umbraco/ContentHelper.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Models; using Umbraco.Web; @@ -37,34 +38,47 @@ public static PropertyType GetFormModelProperty(IContentType contentType) return property; } - public static bool IpDisplayEnabled(IContent document) + public static bool IpDisplayEnabled(IDictionary preValues) { - return PreValueEnabled(document, FormModel.PropertyEditorAlias, "showIp"); + return PreValueEnabled(preValues, "showIp"); } - public static bool IpLoggingEnabled(IContent document) + public static bool IpLoggingEnabled(IDictionary preValues) { - return PreValueEnabled(document, FormModel.PropertyEditorAlias, "logIp"); + return PreValueEnabled(preValues, "logIp"); } - private static bool PreValueEnabled(IContent document, string propertyEditorAlias, string preValueKey) + public static bool StatisticsEnabled(IDictionary preValues) + { + return PreValueEnabled(preValues, "enableStatistics"); + } + + public static IDictionary GetPreValues(IContent document, string propertyEditorAlias) { if (document == null) { - return false; + return null; } var property = document.ContentType.PropertyTypes.FirstOrDefault(p => p.PropertyEditorAlias == propertyEditorAlias); if (property == null) { - return false; + return null; } var preValues = UmbracoContext.Current.Application.Services.DataTypeService.GetPreValuesCollectionByDataTypeId(property.DataTypeDefinitionId); + if (preValues == null) + { + return null; + } + return preValues.PreValuesAsDictionary; + } + + private static bool PreValueEnabled(IDictionary preValues, string preValueKey) + { if (preValues == null) { return false; } - var preValueDictionary = preValues.PreValuesAsDictionary; - return preValueDictionary.ContainsKey(preValueKey) && preValueDictionary[preValueKey] != null && (preValueDictionary[preValueKey].Value == "1"); + return preValues.ContainsKey(preValueKey) && preValues[preValueKey] != null && (preValues[preValueKey].Value == "1"); } } } diff --git a/Source/Umbraco/Plugin/css/form.less b/Source/Umbraco/Plugin/css/form.less index 4893316..b40f8ff 100644 --- a/Source/Umbraco/Plugin/css/form.less +++ b/Source/Umbraco/Plugin/css/form.less @@ -667,3 +667,16 @@ margin-bottom: 10px; } } + +.stats-modal { + margin-left: -500px !important; + &.fade.in { + width: 500px !important; + } + .stats-loading { + vertical-align: middle; + text-align: center; + /* NOTE: this needs to be the same value as the container height (inline CSS in data.html) */ + line-height: 400px; + } +} diff --git a/Source/Umbraco/Plugin/editor/data.html b/Source/Umbraco/Plugin/editor/data.html index d6ce6b2..501a36a 100644 --- a/Source/Umbraco/Plugin/editor/data.html +++ b/Source/Umbraco/Plugin/editor/data.html @@ -1,5 +1,5 @@
-

+

The submissions for your form are listed in the table below.

@@ -105,6 +105,11 @@ Choose visible form fields
+
@@ -177,4 +182,43 @@

+
+ + + +
+ diff --git a/Source/Umbraco/Plugin/js/controllers/editor.data.js b/Source/Umbraco/Plugin/js/controllers/editor.data.js index 0745996..542e2bc 100644 --- a/Source/Umbraco/Plugin/js/controllers/editor.data.js +++ b/Source/Umbraco/Plugin/js/controllers/editor.data.js @@ -96,12 +96,31 @@ row._createdDateLong = $filter("date")(row._createdDate, "yyyy-MM-dd HH:mm:ss"); }); + // make sure we don't end up with a gazillion pagination links if we have lots of submissions + var start, end; + var maxPages = 10; + if (data.totalPages > maxPages) { + start = page - maxPages / 2; + if (start < 1) { + start = 1; + } + end = start + maxPages; + if (end > data.totalPages) { + end = data.totalPages; + start = end - maxPages; + } + } + else { + start = 1; + end = data.totalPages; + } data.pages = []; - for (var i = 1; i <= data.totalPages; i++) { + for (var i = start; i <= end; i++) { data.pages.push(i); } $scope.supportsSearch = data.supportsSearch; + $scope.supportsStatistics = data.supportsStatistics; $scope.actionInProgress = false; $scope.dataState = "data"; $scope.model.data = data; @@ -195,6 +214,14 @@ }, 600); } + $scope.showStatistics = function () { + dialogService.open({ + dialogData: {}, + template: "data.statistics.html", + modalClass: "umb-modal stats-modal" + }); + } + $scope.loadPage(1); } ]); diff --git a/Source/Umbraco/Plugin/js/controllers/editor.statistics.js b/Source/Umbraco/Plugin/js/controllers/editor.statistics.js new file mode 100644 index 0000000..4428f44 --- /dev/null +++ b/Source/Umbraco/Plugin/js/controllers/editor.statistics.js @@ -0,0 +1,75 @@ +angular.module("umbraco").controller("FormEditor.Editor.StatisticsController", ["$scope", "$timeout", "assetsService", "formEditorPropertyEditorResource", "editorState", + function ($scope, $timeout, assetsService, formEditorPropertyEditorResource, editorState) { + + $scope.fields = null; + $scope.loading = true; + + // for the time being we only have field value frequency statistics. hopefully this will be extended over time. + formEditorPropertyEditorResource.getFieldValueFrequencyStatistics(editorState.current.id).then(function (data) { + $scope.fields = data.fields; + $scope.totalRows = data.totalRows; + + // make sure there is actually any statistics to show before loading a whole bunch of graph stuff + if ($scope.fields != null && $scope.fields.length) { + assetsService.loadJs("https://www.gstatic.com/charts/loader.js").then(function () { + if (formEditorPropertyEditorResource.googleChartsLoaded == false) { + google.charts.load("current", { "packages": ["corechart", "bar"] }); + google.charts.setOnLoadCallback(googleChartsLoadCallback); + } + else { + googleChartsLoadCallback(); + } + }); + } + }); + + function googleChartsLoadCallback() { + formEditorPropertyEditorResource.googleChartsLoaded = true; + _.each($scope.fields, function (field) { + field.chartData = [["", ""]]; // legend header - leave empty + _.each(field.values, function (value) { + field.chartData.push([value.value, value.frequency]); + }); + }); + $timeout(function () { + drawCharts(); + }, 200); + } + + function drawCharts() { + _.each($scope.fields, function (field) { + var data = google.visualization.arrayToDataTable(field.chartData); + + var chart, options; + var chartContainer = document.getElementById(field.formSafeName); + + if (field.multipleValuesPerEntry) { + // use bar charts for multiselect fields + options = { + chartArea: { width: "60%" }, + hAxis: { + minValue: 0 + }, + vAxis: { + }, + legend: "none" + }; + chart = new google.visualization.BarChart(chartContainer); + } + else { + // use pie charts for singleselect fields + options = { + chartArea: { left: 20, top: 20 }, + legend: { + position: "bottom" + } + }; + chart = new google.visualization.PieChart(chartContainer); + } + + chart.draw(data, options); + }); + $scope.loading = false; + } + } +]); diff --git a/Source/Umbraco/Plugin/js/langs/da-dk.js b/Source/Umbraco/Plugin/js/langs/da-dk.js index 823c1b7..cc6ab4e 100644 --- a/Source/Umbraco/Plugin/js/langs/da-dk.js +++ b/Source/Umbraco/Plugin/js/langs/da-dk.js @@ -93,6 +93,12 @@ "data.loadingData": "Henter formular-data...", "data.noData": "Ingen formular-data fundet", "data.noSearchResults": "Ingen søgeresultater", + "data.statistics.header": "Statistik", + "data.statistics.loadingData": "Henter statistikken...", + "data.statistics.totalRows": "Total antal:", + "data.statistics.noStatisticsHeader": "Ingen statistik tilgængelig", + "data.statistics.noStatisticsText": "Der kan desværre ikke dannes nogen statistik ud fra de indsendte data.", + "data.statistics.loadingGraph": "Tegner grafen...", "edit.checkbox.checked.helpText": "Sæt kryds her hvis feltet skal være valgt som standard", "edit.checked": "Valgt", diff --git a/Source/Umbraco/Plugin/js/resources/propertyEditorResource.js b/Source/Umbraco/Plugin/js/resources/propertyEditorResource.js index e8e8273..b45b2af 100644 --- a/Source/Umbraco/Plugin/js/resources/propertyEditorResource.js +++ b/Source/Umbraco/Plugin/js/resources/propertyEditorResource.js @@ -1,5 +1,5 @@ -angular.module("umbraco.resources").factory("formEditorPropertyEditorResource", ["$q", "$http", "umbRequestHelper", - function ($q, $http, umbRequestHelper) { +angular.module("umbraco.resources").factory("formEditorPropertyEditorResource", ["$q", "$http", "$timeout", "umbRequestHelper", + function ($q, $http, $timeout, umbRequestHelper) { return { getAllFieldTypes: function () { return umbRequestHelper.resourcePromise( @@ -26,6 +26,11 @@ $http.get("/umbraco/backoffice/FormEditorApi/PropertyEditor/GetData/" + documentId, { params: { page: page, sortField: sortField, sortDescending: sortDescending, searchQuery: searchQuery } }), "Could not retrieve data" ); }, + getFieldValueFrequencyStatistics: function (documentId) { + return umbRequestHelper.resourcePromise( + $http.get("/umbraco/backoffice/FormEditorApi/PropertyEditor/GetFieldValueFrequencyStatistics/" + documentId), "Could not retrieve field value frequency statistics" + ); + }, deleteData: function (documentId, ids) { // posting all IDs for deletion here in one bulk operation .. not quite the REST way but more efficient this way. return umbRequestHelper.resourcePromise( @@ -49,6 +54,8 @@ pathToConditionFile: function (file) { return "/App_Plugins/FormEditor/editor/conditions/" + file; }, + // this indicates if the google charts loader has been executed or not + googleChartsLoaded: false } } ]); diff --git a/Source/Umbraco/Plugin/package.manifest b/Source/Umbraco/Plugin/package.manifest index 5510e76..f21ba73 100644 --- a/Source/Umbraco/Plugin/package.manifest +++ b/Source/Umbraco/Plugin/package.manifest @@ -81,6 +81,12 @@ description: "Enable creation of multiple form page", key: "enablePages", view: "boolean" + }, + { + label: "Use form statistics", + description: "Enable statistics for form submissions", + key: "enableStatistics", + view: "boolean" } ] }